Merge remote-tracking branch 'origin/cline-dev' into cline-dev-tara

:wq
Merge remote-tracking branch 'origin/cline-dev' into cline-dev-tara
This commit is contained in:
Tara West
2026-01-08 14:30:17 +01:00
53 changed files with 8756 additions and 210 deletions

View File

@@ -0,0 +1,149 @@
/**
* JSONEditor Main Component Styles
* Uses design tokens for consistency with OpenNoodl design system
*/
.Root {
display: flex;
flex-direction: column;
width: 100%;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
overflow: hidden;
}
/* Header */
.Header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: var(--theme-color-bg-2);
border-bottom: 1px solid var(--theme-color-border-default);
}
.Title {
font-size: 13px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
/* Mode Toggle */
.ModeToggle {
display: flex;
gap: 4px;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
padding: 2px;
button {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border: none;
background: transparent;
color: var(--theme-color-fg-default);
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-3);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&.Active {
background-color: var(--theme-color-primary);
color: white;
font-weight: 600;
&:hover {
background-color: var(--theme-color-primary);
}
}
}
}
/* Validation Error Banner */
.ValidationError {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
background-color: #fef2f2;
border-bottom: 1px solid #fecaca;
}
.ErrorIcon {
font-size: 20px;
flex-shrink: 0;
line-height: 1;
}
.ErrorContent {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.ErrorMessage {
font-size: 13px;
font-weight: 600;
color: #dc2626;
line-height: 1.4;
}
.ErrorSuggestion {
font-size: 12px;
color: #7c2d12;
line-height: 1.4;
padding: 8px 12px;
background-color: #fef3c7;
border: 1px solid #fde68a;
border-radius: 4px;
margin-top: 4px;
}
/* Editor Content */
.EditorContent {
flex: 1;
overflow: hidden;
position: relative;
}
/* Advanced Mode Placeholder (temporary) */
.AdvancedModePlaceholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px 24px;
background-color: var(--theme-color-bg-2);
text-align: center;
}
.PlaceholderIcon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.PlaceholderText {
font-size: 16px;
font-weight: 600;
color: var(--theme-color-fg-default);
margin-bottom: 8px;
}
.PlaceholderDetail {
font-size: 13px;
color: var(--theme-color-fg-muted);
}

View File

@@ -0,0 +1,158 @@
/**
* JSONEditor Component
*
* A modern, dual-mode JSON editor with:
* - Easy Mode: Visual tree builder (no-code friendly)
* - Advanced Mode: Text editor with validation
*
* @module json-editor
*/
import React, { useState, useEffect, useMemo } from 'react';
import css from './JSONEditor.module.scss';
import { AdvancedMode } from './modes/AdvancedMode/AdvancedMode';
import { EasyMode } from './modes/EasyMode/EasyMode';
import { validateJSON } from './utils/jsonValidator';
import { valueToTreeNode, treeNodeToValue } from './utils/treeConverter';
import { JSONEditorProps, EditorMode, JSONTreeNode } from './utils/types';
/**
* Main JSONEditor Component
*/
export function JSONEditor({
value,
onChange,
onSave,
defaultMode = 'easy',
mode: forcedMode,
expectedType = 'any',
disabled = false,
height,
placeholder
}: JSONEditorProps) {
// Current editor mode
const [currentMode, setCurrentMode] = useState<EditorMode>(forcedMode || defaultMode);
// Internal JSON string state
const [jsonString, setJsonString] = useState<string>(() => {
if (typeof value === 'string') return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return expectedType === 'array' ? '[]' : '{}';
}
});
// Sync with external value changes
useEffect(() => {
if (typeof value === 'string') {
setJsonString(value);
} else {
try {
setJsonString(JSON.stringify(value, null, 2));
} catch {
// Invalid value, keep current
}
}
}, [value]);
// Parse JSON to tree node for Easy Mode
const treeNode: JSONTreeNode | null = useMemo(() => {
try {
const parsed = JSON.parse(jsonString || (expectedType === 'array' ? '[]' : '{}'));
return valueToTreeNode(parsed);
} catch {
// Invalid JSON - return empty structure
return valueToTreeNode(expectedType === 'array' ? [] : {});
}
}, [jsonString, expectedType]);
// Validate current JSON
const validation = useMemo(() => {
return validateJSON(jsonString, expectedType);
}, [jsonString, expectedType]);
// Handle mode toggle
const handleModeChange = (newMode: EditorMode) => {
if (forcedMode) return; // Can't change if forced
setCurrentMode(newMode);
// Save preference to localStorage
try {
localStorage.setItem('json-editor-preferred-mode', newMode);
} catch {
// Ignore storage errors
}
};
// Handle tree changes from Easy Mode
const handleTreeChange = (newNode: JSONTreeNode) => {
try {
const newValue = treeNodeToValue(newNode);
const newJson = JSON.stringify(newValue, null, 2);
setJsonString(newJson);
onChange(newJson);
} catch (error) {
console.error('Failed to convert tree to JSON:', error);
}
};
// Handle text changes from Advanced Mode
const handleTextChange = (newText: string) => {
setJsonString(newText);
onChange(newText);
};
// Container style
const containerStyle: React.CSSProperties = {
height: height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : '400px'
};
return (
<div className={css['Root']} style={containerStyle}>
{/* Header with mode toggle */}
<div className={css['Header']}>
<div className={css['Title']}>JSON Editor</div>
{!forcedMode && (
<div className={css['ModeToggle']}>
<button
className={currentMode === 'easy' ? css['Active'] : ''}
onClick={() => handleModeChange('easy')}
disabled={disabled}
>
Easy
</button>
<button
className={currentMode === 'advanced' ? css['Active'] : ''}
onClick={() => handleModeChange('advanced')}
disabled={disabled}
>
Advanced
</button>
</div>
)}
</div>
{/* Validation Status */}
{!validation.valid && (
<div className={css['ValidationError']}>
<div className={css['ErrorIcon']}></div>
<div className={css['ErrorContent']}>
<div className={css['ErrorMessage']}>{validation.error}</div>
{validation.suggestion && <div className={css['ErrorSuggestion']}>💡 {validation.suggestion}</div>}
</div>
</div>
)}
{/* Editor Content */}
<div className={css['EditorContent']}>
{currentMode === 'easy' ? (
<EasyMode rootNode={treeNode!} onChange={handleTreeChange} disabled={disabled} />
) : (
<AdvancedMode value={jsonString} onChange={handleTextChange} disabled={disabled} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
/**
* JSON Editor Component
*
* Public exports for the JSON Editor component.
*
* @module json-editor
*/
export { JSONEditor } from './JSONEditor';
export type { JSONEditorProps, EditorMode, JSONValueType, ValidationResult } from './utils/types';
export { validateJSON, formatJSON, isValidJSON } from './utils/jsonValidator';

View File

@@ -0,0 +1,156 @@
/**
* Advanced Mode Styles
* Text editor for power users
*/
.Root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--theme-color-bg-2);
border-radius: 4px;
overflow: hidden;
}
/* Toolbar */
.Toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-border-default);
}
.ToolbarLeft,
.ToolbarRight {
display: flex;
align-items: center;
gap: 12px;
}
.StatusValid {
font-size: 13px;
font-weight: 500;
color: #10b981;
}
.StatusInvalid {
font-size: 13px;
font-weight: 500;
color: #ef4444;
}
.FormatButton {
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
background-color: var(--theme-color-primary);
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
/* Text Editor */
.Editor {
flex: 1;
padding: 12px;
font-size: 13px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
line-height: 1.6;
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-default);
border: none;
outline: none;
resize: none;
overflow-y: auto;
&::placeholder {
color: var(--theme-color-fg-muted);
opacity: 0.5;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
/* Error Panel */
.ErrorPanel {
padding: 12px;
background-color: #ef44441a;
border-top: 2px solid #ef4444;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ErrorHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.ErrorIcon {
font-size: 16px;
}
.ErrorTitle {
font-size: 14px;
font-weight: 600;
color: #ef4444;
}
.ErrorMessage {
font-size: 13px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
color: var(--theme-color-fg-default);
margin-bottom: 8px;
padding: 8px;
background-color: var(--theme-color-bg-2);
border-radius: 3px;
border-left: 3px solid #ef4444;
}
.ErrorSuggestion {
font-size: 13px;
color: var(--theme-color-fg-default);
padding: 8px;
background-color: #3b82f61a;
border-radius: 3px;
border-left: 3px solid #3b82f6;
margin-bottom: 8px;
strong {
color: #3b82f6;
}
}
.ErrorLocation {
font-size: 12px;
font-weight: 500;
color: var(--theme-color-fg-muted);
}

View File

@@ -0,0 +1,122 @@
/**
* Advanced Mode - Text Editor for Power Users
*
* A text-based JSON editor with syntax highlighting, validation,
* and helpful error messages. For users who prefer direct editing.
*
* @module json-editor/modes
*/
import React, { useState, useEffect } from 'react';
import { validateJSON } from '../../utils/jsonValidator';
import css from './AdvancedMode.module.scss';
export interface AdvancedModeProps {
/** Current JSON string value */
value: string;
/** Called when value changes */
onChange?: (value: string) => void;
/** Readonly mode */
disabled?: boolean;
}
/**
* Advanced text editor mode
*/
export function AdvancedMode({ value, onChange, disabled }: AdvancedModeProps) {
const [localValue, setLocalValue] = useState(value);
const [validationResult, setValidationResult] = useState(validateJSON(value));
// Sync with external value changes
useEffect(() => {
setLocalValue(value);
setValidationResult(validateJSON(value));
}, [value]);
// Handle text changes
const handleChange = (newValue: string) => {
setLocalValue(newValue);
const result = validateJSON(newValue);
setValidationResult(result);
// Only propagate valid JSON changes
if (result.valid && onChange) {
onChange(newValue);
}
};
// Format/pretty-print JSON
const handleFormat = () => {
if (validationResult.valid) {
try {
const parsed = JSON.parse(localValue);
const formatted = JSON.stringify(parsed, null, 2);
setLocalValue(formatted);
if (onChange) {
onChange(formatted);
}
} catch (e) {
// Should not happen if validation passed
console.error('Format error:', e);
}
}
};
return (
<div className={css['Root']}>
{/* Toolbar */}
<div className={css['Toolbar']}>
<div className={css['ToolbarLeft']}>
{validationResult.valid ? (
<span className={css['StatusValid']}> Valid JSON</span>
) : (
<span className={css['StatusInvalid']}> Invalid JSON</span>
)}
</div>
<div className={css['ToolbarRight']}>
<button
onClick={handleFormat}
disabled={!validationResult.valid || disabled}
className={css['FormatButton']}
title="Format JSON (Ctrl+Shift+F)"
>
Format
</button>
</div>
</div>
{/* Text Editor */}
<textarea
value={localValue}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled}
className={css['Editor']}
placeholder='{\n "key": "value"\n}'
spellCheck={false}
/>
{/* Validation Errors */}
{!validationResult.valid && (
<div className={css['ErrorPanel']}>
<div className={css['ErrorHeader']}>
<span className={css['ErrorIcon']}></span>
<span className={css['ErrorTitle']}>JSON Syntax Error</span>
</div>
<div className={css['ErrorMessage']}>{validationResult.error}</div>
{validationResult.suggestion && (
<div className={css['ErrorSuggestion']}>
<strong>💡 Suggestion:</strong> {validationResult.suggestion}
</div>
)}
{validationResult.line !== undefined && (
<div className={css['ErrorLocation']}>
Line {validationResult.line}
{validationResult.column !== undefined && `, Column ${validationResult.column}`}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,283 @@
/**
* Easy Mode Styles
* Uses design tokens for consistency with OpenNoodl design system
*/
.Root {
width: 100%;
height: 100%;
overflow-y: auto;
padding: 12px;
background-color: var(--theme-color-bg-2);
border-radius: 4px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 13px;
}
/* Empty State */
.EmptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 150px;
padding: 24px;
text-align: center;
}
.EmptyIcon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.EmptyText {
font-size: 13px;
color: var(--theme-color-fg-muted);
}
/* Tree Node */
.TreeNode {
margin-bottom: 4px;
}
.NodeHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 3px;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-3);
}
}
/* Node Labels */
.Key {
color: var(--theme-color-primary);
font-weight: 500;
}
.Index {
color: var(--theme-color-fg-muted);
font-weight: 500;
}
.Colon {
color: var(--theme-color-fg-muted);
}
/* Type Badge */
.TypeBadge {
display: inline-block;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
border-radius: 3px;
letter-spacing: 0.5px;
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-muted);
border: 1px solid var(--theme-color-border-default);
&[data-type='string'] {
background-color: #3b82f61a;
color: #3b82f6;
border-color: #3b82f64d;
}
&[data-type='number'] {
background-color: #10b9811a;
color: #10b981;
border-color: #10b9814d;
}
&[data-type='boolean'] {
background-color: #f59e0b1a;
color: #f59e0b;
border-color: #f59e0b4d;
}
&[data-type='null'] {
background-color: #6b72801a;
color: #6b7280;
border-color: #6b72804d;
}
&[data-type='array'] {
background-color: #8b5cf61a;
color: #8b5cf6;
border-color: #8b5cf64d;
}
&[data-type='object'] {
background-color: #ec48991a;
color: #ec4899;
border-color: #ec48994d;
}
}
/* Value Display */
.Value {
color: var(--theme-color-fg-default);
font-family: inherit;
&[data-type='string'] {
color: #3b82f6;
}
&[data-type='number'] {
color: #10b981;
}
&[data-type='boolean'] {
color: #f59e0b;
}
&[data-type='null'] {
color: #6b7280;
font-style: italic;
}
}
/* Count Badge */
.Count {
font-size: 11px;
color: var(--theme-color-fg-muted);
font-weight: 400;
}
/* Children Container */
.Children {
margin-left: 0;
border-left: 1px solid var(--theme-color-border-default);
padding-left: 8px;
margin-top: 2px;
}
/* Action Buttons */
.EditButton,
.AddButton,
.DeleteButton {
padding: 2px 8px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
margin-left: 6px;
}
.EditButton {
background-color: var(--theme-color-bg-3);
color: var(--theme-color-primary);
&:hover {
background-color: var(--theme-color-primary);
color: white;
}
}
.AddButton {
background-color: var(--theme-color-primary);
color: white;
font-size: 11px;
&:hover {
opacity: 0.9;
}
}
.DeleteButton {
background-color: #dc2626;
color: white;
font-size: 14px;
font-weight: bold;
line-height: 1;
&:hover {
background-color: #b91c1c;
}
}
/* Add Item Form */
.AddItemForm {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
margin: 4px 0;
background-color: var(--theme-color-bg-3);
border-radius: 4px;
border: 1px solid var(--theme-color-border-default);
input[type='text'] {
flex: 1;
padding: 4px 8px;
font-size: 13px;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 3px;
color: var(--theme-color-fg-default);
&::placeholder {
color: var(--theme-color-fg-muted);
}
}
select {
padding: 4px 8px;
font-size: 13px;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 3px;
color: var(--theme-color-fg-default);
cursor: pointer;
}
button {
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
&:first-of-type {
background-color: var(--theme-color-primary);
color: white;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&:last-of-type {
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-default);
border: 1px solid var(--theme-color-border-default);
&:hover {
background-color: var(--theme-color-bg-1);
}
}
}
}
/* Clickable value for editing */
.Value {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}

View File

@@ -0,0 +1,356 @@
/**
* Easy Mode - Visual JSON Tree Builder (WITH EDITING)
*
* A visual tree-based editor where users can't break JSON structure.
* Perfect for no-coders who don't understand JSON syntax.
*
* @module json-editor/modes
*/
import React, { useState } from 'react';
import { setValueAtPath, deleteValueAtPath, treeNodeToValue, valueToTreeNode } from '../../utils/treeConverter';
import { JSONTreeNode, JSONValueType } from '../../utils/types';
import css from './EasyMode.module.scss';
import { ValueEditor } from './ValueEditor';
export interface EasyModeProps {
rootNode: JSONTreeNode;
onChange?: (node: JSONTreeNode) => void;
disabled?: boolean;
}
interface TreeNodeProps {
node: JSONTreeNode;
depth: number;
onEdit: (path: (string | number)[], value: unknown) => void;
onDelete: (path: (string | number)[]) => void;
onAdd: (path: (string | number)[], type: JSONValueType, key?: string) => void;
disabled?: boolean;
}
/**
* Editable Tree Node Component
*/
function EditableTreeNode({ node, depth, onEdit, onDelete, onAdd, disabled }: TreeNodeProps) {
const [isEditing, setIsEditing] = useState(false);
const [isAddingItem, setIsAddingItem] = useState(false);
const [newItemType, setNewItemType] = useState<JSONValueType>('string');
const [newKey, setNewKey] = useState('');
const hasChildren = node.children && node.children.length > 0;
const indent = depth * 20;
const canEdit =
!disabled && (node.type === 'string' || node.type === 'number' || node.type === 'boolean' || node.type === 'null');
const canDelete = !disabled && depth > 0; // Can't delete root
// Type badge
const typeBadge = (
<span className={css['TypeBadge']} data-type={node.type}>
{node.type}
</span>
);
// Handle saving edited value
const handleSaveEdit = (value: unknown) => {
onEdit(node.path, value);
setIsEditing(false);
};
// Handle adding new item/property
const handleAddItem = () => {
if (node.type === 'array') {
onAdd(node.path, newItemType);
} else if (node.type === 'object') {
if (!newKey.trim()) return;
onAdd(node.path, newItemType, newKey.trim());
}
setIsAddingItem(false);
setNewKey('');
setNewItemType('string');
};
// Arrays
if (node.type === 'array') {
return (
<div className={css['TreeNode']} style={{ marginLeft: `${indent}px` }}>
<div className={css['NodeHeader']}>
{node.key && (
<>
<span className={css['Key']}>{node.key}</span>
<span className={css['Colon']}>:</span>
</>
)}
{node.index !== undefined && <span className={css['Index']}>[{node.index}]</span>}
{typeBadge}
<span className={css['Count']}>({node.children?.length || 0} items)</span>
{!disabled && (
<button onClick={() => setIsAddingItem(!isAddingItem)} className={css['AddButton']} title="Add item">
+ Add Item
</button>
)}
{canDelete && (
<button onClick={() => onDelete(node.path)} className={css['DeleteButton']} title="Delete">
</button>
)}
</div>
{isAddingItem && (
<div className={css['AddItemForm']}>
<select value={newItemType} onChange={(e) => setNewItemType(e.target.value as JSONValueType)}>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="null">Null</option>
<option value="array">Array</option>
<option value="object">Object</option>
</select>
<button onClick={handleAddItem}>Add</button>
<button onClick={() => setIsAddingItem(false)}>Cancel</button>
</div>
)}
{hasChildren && (
<div className={css['Children']}>
{node.children!.map((child, idx) => (
<EditableTreeNode
key={child.id || idx}
node={child}
depth={depth + 1}
onEdit={onEdit}
onDelete={onDelete}
onAdd={onAdd}
disabled={disabled}
/>
))}
</div>
)}
</div>
);
}
// Objects
if (node.type === 'object') {
return (
<div className={css['TreeNode']} style={{ marginLeft: `${indent}px` }}>
<div className={css['NodeHeader']}>
{node.key && (
<>
<span className={css['Key']}>{node.key}</span>
<span className={css['Colon']}>:</span>
</>
)}
{node.index !== undefined && <span className={css['Index']}>[{node.index}]</span>}
{typeBadge}
<span className={css['Count']}>({node.children?.length || 0} properties)</span>
{!disabled && (
<button onClick={() => setIsAddingItem(!isAddingItem)} className={css['AddButton']} title="Add property">
+ Add Property
</button>
)}
{canDelete && (
<button onClick={() => onDelete(node.path)} className={css['DeleteButton']} title="Delete">
</button>
)}
</div>
{isAddingItem && (
<div className={css['AddItemForm']}>
<input type="text" placeholder="Property key" value={newKey} onChange={(e) => setNewKey(e.target.value)} />
<select value={newItemType} onChange={(e) => setNewItemType(e.target.value as JSONValueType)}>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="null">Null</option>
<option value="array">Array</option>
<option value="object">Object</option>
</select>
<button onClick={handleAddItem} disabled={!newKey.trim()}>
Add
</button>
<button onClick={() => setIsAddingItem(false)}>Cancel</button>
</div>
)}
{hasChildren && (
<div className={css['Children']}>
{node.children!.map((child, idx) => (
<EditableTreeNode
key={child.id || idx}
node={child}
depth={depth + 1}
onEdit={onEdit}
onDelete={onDelete}
onAdd={onAdd}
disabled={disabled}
/>
))}
</div>
)}
</div>
);
}
// Primitive values
return (
<div className={css['TreeNode']} style={{ marginLeft: `${indent}px` }}>
<div className={css['NodeHeader']}>
{node.key && (
<>
<span className={css['Key']}>{node.key}</span>
<span className={css['Colon']}>:</span>
</>
)}
{node.index !== undefined && <span className={css['Index']}>[{node.index}]</span>}
{typeBadge}
{isEditing ? (
<ValueEditor
value={node.value!}
type={node.type}
onSave={handleSaveEdit}
onCancel={() => setIsEditing(false)}
/>
) : (
<>
<span className={css['Value']} data-type={node.type} onClick={() => canEdit && setIsEditing(true)}>
{formatValue(node.value, node.type)}
</span>
{canEdit && (
<button onClick={() => setIsEditing(true)} className={css['EditButton']} title="Edit">
</button>
)}
</>
)}
{canDelete && (
<button onClick={() => onDelete(node.path)} className={css['DeleteButton']} title="Delete">
</button>
)}
</div>
</div>
);
}
function formatValue(value: unknown, type: string): string {
if (type === 'null') return 'null';
if (type === 'boolean') return value ? 'true' : 'false';
if (type === 'string') return `"${value}"`;
return String(value);
}
/**
* Easy Mode Component
*/
export function EasyMode({ rootNode, onChange, disabled }: EasyModeProps) {
// Handle editing a value at a path
const handleEdit = (path: (string | number)[], value: unknown) => {
if (!onChange) return;
const currentValue = treeNodeToValue(rootNode);
const newValue = setValueAtPath(currentValue, path, value);
const newNode = valueToTreeNode(newValue);
onChange(newNode);
};
// Handle deleting a value at a path
const handleDelete = (path: (string | number)[]) => {
if (!onChange) return;
const currentValue = treeNodeToValue(rootNode);
const newValue = deleteValueAtPath(currentValue, path);
const newNode = valueToTreeNode(newValue);
onChange(newNode);
};
// Handle adding a new item/property
const handleAdd = (path: (string | number)[], type: JSONValueType, key?: string) => {
if (!onChange) return;
const currentValue = treeNodeToValue(rootNode);
// Get the parent value at path
let parent = currentValue;
for (const segment of path) {
parent = (parent as any)[segment];
}
// Create default value for the type
let defaultValue: any;
if (type === 'string') defaultValue = '';
else if (type === 'number') defaultValue = 0;
else if (type === 'boolean') defaultValue = false;
else if (type === 'null') defaultValue = null;
else if (type === 'array') defaultValue = [];
else if (type === 'object') defaultValue = {};
// Add to parent
if (Array.isArray(parent)) {
(parent as any[]).push(defaultValue);
} else if (typeof parent === 'object' && parent !== null && key) {
(parent as any)[key] = defaultValue;
}
const newNode = valueToTreeNode(currentValue);
onChange(newNode);
};
// Empty state
if (!rootNode || (rootNode.type === 'array' && !rootNode.children?.length)) {
return (
<div className={css['Root']}>
<div className={css['EmptyState']}>
<div className={css['EmptyIcon']}>📋</div>
<div className={css['EmptyText']}>Empty array - click "Add Item" to start</div>
</div>
{!disabled && (
<EditableTreeNode
node={rootNode || valueToTreeNode([])}
depth={0}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
disabled={disabled}
/>
)}
</div>
);
}
if (rootNode.type === 'object' && !rootNode.children?.length) {
return (
<div className={css['Root']}>
<div className={css['EmptyState']}>
<div className={css['EmptyIcon']}>📦</div>
<div className={css['EmptyText']}>Empty object - click "Add Property" to start</div>
</div>
{!disabled && (
<EditableTreeNode
node={rootNode || valueToTreeNode({})}
depth={0}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
disabled={disabled}
/>
)}
</div>
);
}
return (
<div className={css['Root']}>
<EditableTreeNode
node={rootNode}
depth={0}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
disabled={disabled}
/>
</div>
);
}

View File

@@ -0,0 +1,121 @@
/**
* ValueEditor Styles
*/
/* Input Editor (String/Number) */
.InputEditor {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.Input {
flex: 1;
padding: 4px 8px;
font-size: 13px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-primary);
border-radius: 3px;
color: var(--theme-color-fg-default);
outline: none;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
&:focus {
border-color: var(--theme-color-primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
&::placeholder {
color: var(--theme-color-fg-muted);
opacity: 0.6;
}
}
.ButtonGroup {
display: flex;
gap: 4px;
}
.SaveButton,
.CancelButton,
.CloseButton {
padding: 4px 8px;
font-size: 14px;
font-weight: bold;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
line-height: 1;
}
.SaveButton {
background-color: var(--theme-color-primary);
color: white;
&:hover {
opacity: 0.9;
}
}
.CancelButton,
.CloseButton {
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
&:hover {
background-color: var(--theme-color-bg-2);
}
}
/* Boolean Editor */
.BooleanEditor {
display: flex;
align-items: center;
}
.BooleanToggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 3px;
background-color: var(--theme-color-bg-3);
transition: background-color 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-2);
}
input[type='checkbox'] {
width: 16px;
height: 16px;
cursor: pointer;
}
}
.ToggleLabel {
font-size: 13px;
font-weight: 500;
color: var(--theme-color-fg-default);
user-select: none;
}
/* Null Editor */
.NullEditor {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
background-color: var(--theme-color-bg-3);
border-radius: 3px;
}
.NullValue {
font-size: 13px;
font-style: italic;
color: #6b7280;
}

View File

@@ -0,0 +1,140 @@
/**
* ValueEditor Component
*
* Type-aware inline editor for primitive JSON values.
* Provides appropriate input controls for each type.
*
* @module json-editor/modes/EasyMode
*/
import React, { useState, useEffect, useRef } from 'react';
import { JSONValueType } from '../../utils/types';
import css from './ValueEditor.module.scss';
export interface ValueEditorProps {
/** Current value */
value: string | number | boolean | null;
/** Value type */
type: JSONValueType;
/** Called when value is saved */
onSave: (value: unknown) => void;
/** Called when editing is cancelled */
onCancel: () => void;
/** Auto-focus on mount */
autoFocus?: boolean;
}
/**
* Inline editor for primitive values
*/
export function ValueEditor({ value, type, onSave, onCancel, autoFocus = true }: ValueEditorProps) {
const [localValue, setLocalValue] = useState<string>(() => {
if (type === 'null') return 'null';
if (type === 'boolean') return value ? 'true' : 'false';
return String(value ?? '');
});
const inputRef = useRef<HTMLInputElement>(null);
// Auto-focus on mount
useEffect(() => {
if (autoFocus && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [autoFocus]);
const handleSave = () => {
// Convert string to appropriate type
let parsedValue: unknown;
if (type === 'null') {
parsedValue = null;
} else if (type === 'boolean') {
parsedValue = localValue === 'true';
} else if (type === 'number') {
const num = Number(localValue);
if (isNaN(num)) {
// Invalid number, cancel
onCancel();
return;
}
parsedValue = num;
} else {
// String
parsedValue = localValue;
}
onSave(parsedValue);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
// Boolean toggle
if (type === 'boolean') {
return (
<div className={css['BooleanEditor']}>
<label className={css['BooleanToggle']}>
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
type="checkbox"
checked={localValue === 'true'}
onChange={(e) => {
const newValue = e.target.checked ? 'true' : 'false';
setLocalValue(newValue);
// Auto-save on toggle
onSave(e.target.checked);
}}
autoFocus={autoFocus}
/>
<span className={css['ToggleLabel']}>{localValue}</span>
</label>
</div>
);
}
// Null (read-only, just show the value)
if (type === 'null') {
return (
<div className={css['NullEditor']}>
<span className={css['NullValue']}>null</span>
<button onClick={onCancel} className={css['CloseButton']}>
</button>
</div>
);
}
// String and Number input
return (
<div className={css['InputEditor']}>
<input
ref={inputRef}
type={type === 'number' ? 'number' : 'text'}
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
className={css['Input']}
placeholder={type === 'string' ? 'Enter text...' : 'Enter number...'}
/>
<div className={css['ButtonGroup']}>
<button onClick={handleSave} className={css['SaveButton']} title="Save (Enter)">
</button>
<button onClick={onCancel} className={css['CancelButton']} title="Cancel (Esc)">
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,254 @@
/**
* JSON Validator with Helpful Error Messages
*
* Validates JSON and provides detailed error information with
* suggested fixes for common mistakes. Designed to be helpful
* for no-coders who may not understand JSON syntax.
*
* @module json-editor/utils
*/
import { ValidationResult } from './types';
/**
* Validates JSON string and provides detailed error information with fix suggestions.
*
* @param jsonString - The JSON string to validate
* @param expectedType - Optional type constraint ('array' | 'object' | 'any')
* @returns Validation result with error details and suggestions
*
* @example
* ```typescript
* const result = validateJSON('[1, 2, 3]', 'array');
* if (result.valid) {
* console.log('Valid!', result.value);
* } else {
* console.error(result.error, result.suggestion);
* }
* ```
*/
export function validateJSON(jsonString: string, expectedType?: 'array' | 'object' | 'any'): ValidationResult {
// Handle empty input
if (!jsonString || jsonString.trim() === '') {
return {
valid: false,
error: 'Empty input',
suggestion: expectedType === 'array' ? 'Start with [ ] for an array' : 'Start with { } for an object'
};
}
const trimmed = jsonString.trim();
try {
// Attempt to parse
const parsed = JSON.parse(trimmed);
// Check type constraints
if (expectedType === 'array' && !Array.isArray(parsed)) {
return {
valid: false,
error: 'Expected an array, but got an object',
suggestion: 'Wrap your data in [ ] brackets to make it an array. Example: [ {...} ]'
};
}
if (expectedType === 'object' && (typeof parsed !== 'object' || Array.isArray(parsed))) {
return {
valid: false,
error: 'Expected an object, but got ' + (Array.isArray(parsed) ? 'an array' : typeof parsed),
suggestion: 'Wrap your data in { } braces to make it an object. Example: { "key": "value" }'
};
}
// Valid!
return {
valid: true,
value: parsed
};
} catch (error) {
// Parse the error to provide helpful feedback
return parseJSONError(error as SyntaxError, trimmed);
}
}
/**
* Parses a JSON.parse() error and extracts helpful information
*
* @param error - The SyntaxError from JSON.parse()
* @param jsonString - The original JSON string
* @returns Validation result with detailed error and suggestions
*/
function parseJSONError(error: SyntaxError, jsonString: string): ValidationResult {
const message = error.message;
// Extract position from error message
// Error messages typically look like: "Unexpected token } in JSON at position 42"
const positionMatch = message.match(/position (\d+)/);
const position = positionMatch ? parseInt(positionMatch[1], 10) : null;
// Calculate line and column from position
let line = 1;
let column = 1;
if (position !== null) {
const lines = jsonString.substring(0, position).split('\n');
line = lines.length;
column = lines[lines.length - 1].length + 1;
}
// Analyze the error and provide helpful suggestions
const suggestion = getErrorSuggestion(message, jsonString, position);
return {
valid: false,
error: `Line ${line}, Column ${column}: ${extractSimpleError(message)}`,
line,
column,
suggestion
};
}
/**
* Extracts a simple, user-friendly error message from the technical error
*
* @param message - The technical error message from JSON.parse()
* @returns Simplified error message
*/
function extractSimpleError(message: string): string {
// Common patterns
if (message.includes('Unexpected token')) {
const tokenMatch = message.match(/Unexpected token ([^\s]+)/);
const token = tokenMatch ? tokenMatch[1] : 'character';
return `Unexpected ${token}`;
}
if (message.includes('Unexpected end of JSON')) {
return 'Unexpected end of JSON';
}
if (message.includes('Unexpected string')) {
return 'Unexpected string';
}
if (message.includes('Unexpected number')) {
return 'Unexpected number';
}
// Default to original message
return message;
}
/**
* Provides a helpful suggestion based on the error message and context
*
* @param message - The error message from JSON.parse()
* @param jsonString - The original JSON string
* @param position - The error position in the string
* @returns A helpful suggestion for fixing the error
*/
function getErrorSuggestion(message: string, jsonString: string, position: number | null): string {
const lower = message.toLowerCase();
// Missing closing bracket/brace
if (lower.includes('unexpected end of json') || lower.includes('unterminated')) {
const openBraces = (jsonString.match(/{/g) || []).length;
const closeBraces = (jsonString.match(/}/g) || []).length;
const openBrackets = (jsonString.match(/\[/g) || []).length;
const closeBrackets = (jsonString.match(/]/g) || []).length;
if (openBraces > closeBraces) {
return `Missing ${openBraces - closeBraces} closing } brace(s) at the end`;
}
if (openBrackets > closeBrackets) {
return `Missing ${openBrackets - closeBrackets} closing ] bracket(s) at the end`;
}
return 'Check if you have matching { } braces and [ ] brackets';
}
// Missing comma
if (lower.includes('unexpected token') && position !== null) {
const context = jsonString.substring(Math.max(0, position - 20), position);
const nextChar = jsonString[position];
// Check if previous line ended without comma
if (context.includes('"') && (nextChar === '"' || nextChar === '{' || nextChar === '[')) {
return 'Add a comma (,) after the previous property or value';
}
// Unexpected } or ]
if (nextChar === '}' || nextChar === ']') {
return 'Remove the trailing comma before the closing bracket';
}
// Unexpected :
if (nextChar === ':') {
return 'Property keys must be wrapped in "quotes"';
}
}
// Unexpected token } or ]
if (lower.includes('unexpected token }')) {
return 'Either remove this extra } brace, or add a comma before it if there should be more properties';
}
if (lower.includes('unexpected token ]')) {
return 'Either remove this extra ] bracket, or add a comma before it if there should be more items';
}
// Unexpected string
if (lower.includes('unexpected string')) {
return 'Add a comma (,) between values, or check if a property key needs a colon (:)';
}
// Missing quotes around key
if (lower.includes('unexpected token') && position !== null && jsonString[position] !== '"') {
const context = jsonString.substring(Math.max(0, position - 10), position + 10);
if (context.includes(':')) {
return 'Property keys must be wrapped in "double quotes"';
}
}
// Single quotes instead of double quotes
if (jsonString.includes("'")) {
return 'JSON requires "double quotes" for strings, not \'single quotes\'';
}
// Trailing comma
if (lower.includes('unexpected token }') || lower.includes('unexpected token ]')) {
return 'Remove the trailing comma before the closing bracket';
}
// Generic help
return 'Check for common issues: missing commas, unmatched brackets { } [ ], or keys without "quotes"';
}
/**
* Formats JSON string with proper indentation
*
* @param jsonString - The JSON string to format
* @param indent - Number of spaces for indentation (default: 2)
* @returns Formatted JSON string or original if invalid
*/
export function formatJSON(jsonString: string, indent: number = 2): string {
try {
const parsed = JSON.parse(jsonString);
return JSON.stringify(parsed, null, indent);
} catch {
// Return original if invalid
return jsonString;
}
}
/**
* Checks if a string is valid JSON without throwing errors
*
* @param jsonString - The string to check
* @returns True if valid JSON
*/
export function isValidJSON(jsonString: string): boolean {
try {
JSON.parse(jsonString);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,189 @@
/**
* Tree Converter Utility
*
* Converts between JSON values and tree node representations
* for the Easy Mode visual editor.
*
* @module json-editor/utils
*/
import { JSONTreeNode, JSONValueType } from './types';
/**
* Converts a JSON value to a tree node structure for Easy Mode display
*
* @param value - The JSON value to convert
* @param path - Current path in the tree (for editing)
* @param key - Object key (if this is an object property)
* @param index - Array index (if this is an array element)
* @returns Tree node representation
*/
export function valueToTreeNode(
value: unknown,
path: (string | number)[] = [],
key?: string,
index?: number
): JSONTreeNode {
const type = getValueType(value);
const id = path.length === 0 ? 'root' : path.join('.');
const baseNode: JSONTreeNode = {
id,
type,
path,
key,
index,
isExpanded: true // Default to expanded
};
if (type === 'array') {
const arr = value as unknown[];
return {
...baseNode,
children: arr.map((item, idx) => valueToTreeNode(item, [...path, idx], undefined, idx))
};
}
if (type === 'object') {
const obj = value as Record<string, unknown>;
return {
...baseNode,
children: Object.entries(obj).map(([k, v]) => valueToTreeNode(v, [...path, k], k))
};
}
// Primitive values
return {
...baseNode,
value: value as string | number | boolean | null
};
}
/**
* Converts a tree node back to a JSON value
*
* @param node - The tree node to convert
* @returns JSON value
*/
export function treeNodeToValue(node: JSONTreeNode): unknown {
if (node.type === 'array') {
return (node.children || []).map(treeNodeToValue);
}
if (node.type === 'object') {
const result: Record<string, unknown> = {};
(node.children || []).forEach((child) => {
if (child.key) {
result[child.key] = treeNodeToValue(child);
}
});
return result;
}
// Primitive values
return node.value;
}
/**
* Determines the JSON value type
*
* @param value - The value to check
* @returns The JSON value type
*/
export function getValueType(value: unknown): JSONValueType {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
if (typeof value === 'object') return 'object';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return 'number';
return 'string';
}
/**
* Gets a value from an object/array using a path
*
* @param root - The root object/array
* @param path - Path to the value
* @returns The value at the path, or undefined
*/
export function getValueAtPath(root: unknown, path: (string | number)[]): unknown {
let current = root;
for (const segment of path) {
if (current === null || current === undefined) return undefined;
if (typeof current === 'object') {
current = (current as Record<string | number, unknown>)[segment];
} else {
return undefined;
}
}
return current;
}
/**
* Sets a value in an object/array using a path (immutably)
*
* @param root - The root object/array
* @param path - Path to set the value
* @param value - Value to set
* @returns New root with the value updated
*/
export function setValueAtPath(root: unknown, path: (string | number)[], value: unknown): unknown {
if (path.length === 0) return value;
const [first, ...rest] = path;
if (Array.isArray(root)) {
const newArray = [...root];
if (typeof first === 'number') {
newArray[first] = rest.length === 0 ? value : setValueAtPath(newArray[first], rest, value);
}
return newArray;
}
if (typeof root === 'object' && root !== null) {
return {
...root,
[first]:
rest.length === 0 ? value : setValueAtPath((root as Record<string | number, unknown>)[first], rest, value)
};
}
return root;
}
/**
* Deletes a value in an object/array using a path (immutably)
*
* @param root - The root object/array
* @param path - Path to delete
* @returns New root with the value deleted
*/
export function deleteValueAtPath(root: unknown, path: (string | number)[]): unknown {
if (path.length === 0) return root;
const [first, ...rest] = path;
if (Array.isArray(root)) {
if (rest.length === 0 && typeof first === 'number') {
return root.filter((_, idx) => idx !== first);
}
const newArray = [...root];
if (typeof first === 'number') {
newArray[first] = deleteValueAtPath(newArray[first], rest);
}
return newArray;
}
if (typeof root === 'object' && root !== null) {
if (rest.length === 0) {
const { [first]: _, ...newObj } = root as Record<string | number, unknown>;
return newObj;
}
return {
...root,
[first]: deleteValueAtPath((root as Record<string | number, unknown>)[first], rest)
};
}
return root;
}

View File

@@ -0,0 +1,100 @@
/**
* JSON Editor Component Types
*
* Type definitions for the unified JSON editor component with
* Easy Mode (visual builder) and Advanced Mode (text editor).
*
* @module json-editor
*/
/**
* JSON value types supported by the editor
*/
export type JSONValueType = 'string' | 'number' | 'boolean' | 'null' | 'array' | 'object';
/**
* Validation result with detailed error information and fix suggestions
*/
export interface ValidationResult {
/** Whether the JSON is valid */
valid: boolean;
/** Error message if invalid */
error?: string;
/** Line number where error occurred (1-indexed) */
line?: number;
/** Column number where error occurred (1-indexed) */
column?: number;
/** Suggested fix for the error */
suggestion?: string;
/** Parsed value if valid */
value?: unknown;
}
/**
* Editor mode: Easy (visual) or Advanced (text)
*/
export type EditorMode = 'easy' | 'advanced';
/**
* Props for the main JSONEditor component
*/
export interface JSONEditorProps {
/** Initial value (JSON string or parsed object/array) */
value: string | object | unknown[];
/** Called when value changes (debounced) */
onChange: (value: string) => void;
/** Called on explicit save (Cmd+S or button) */
onSave?: (value: string) => void;
/** Initial mode - defaults to 'easy' */
defaultMode?: EditorMode;
/** Force a specific mode (no toggle shown) */
mode?: EditorMode;
/** Type constraint for validation */
expectedType?: 'array' | 'object' | 'any';
/** Readonly mode */
disabled?: boolean;
/** Height constraint */
height?: number | string;
/** Custom placeholder for empty state */
placeholder?: string;
}
/**
* Internal tree node representation for Easy Mode
*/
export interface JSONTreeNode {
/** Unique identifier for this node */
id: string;
/** Node type */
type: JSONValueType;
/** For object keys */
key?: string;
/** For array indices */
index?: number;
/** The actual value (primitives) or undefined (arrays/objects) */
value?: string | number | boolean | null;
/** Child nodes for arrays/objects */
children?: JSONTreeNode[];
/** Whether this node is expanded */
isExpanded?: boolean;
/** Path to this node (for editing) */
path: (string | number)[];
}
/**
* Action types for tree node operations
*/
export type TreeAction =
| { type: 'add'; path: (string | number)[]; valueType: JSONValueType; key?: string }
| { type: 'edit'; path: (string | number)[]; value: unknown }
| { type: 'delete'; path: (string | number)[] }
| { type: 'toggle'; path: (string | number)[] }
| { type: 'reorder'; path: (string | number)[]; fromIndex: number; toIndex: number };

View File

@@ -21,7 +21,7 @@ import '../editor/src/styles/custom-properties/spacing.css';
import Router from './src/router';
// Build canary: Verify fresh code is loading
console.log('🔥 BUILD TIMESTAMP:', new Date().toISOString());
console.log('🔥🔥 BUILD TIMESTAMP:', new Date().toISOString());
ipcRenderer.on('open-noodl-uri', async (event, uri) => {
if (uri.startsWith('noodl:import/http')) {

View File

@@ -881,6 +881,82 @@ export class ProjectModel extends Model {
}
}
// App Configuration Methods
/**
* Gets the app configuration from project metadata.
* @returns The app config object
*/
getAppConfig() {
// Import types dynamically to avoid circular dependencies
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { DEFAULT_APP_CONFIG } = require('@noodl/runtime/src/config/types');
return this.getMetaData('appConfig') || DEFAULT_APP_CONFIG;
}
/**
* Sets the app configuration in project metadata.
* @param config - The app config to save
*/
setAppConfig(config) {
this.setMetaData('appConfig', config);
}
/**
* Updates the app configuration with partial values.
* @param updates - Partial app config updates
*/
updateAppConfig(updates) {
const current = this.getAppConfig();
this.setAppConfig({
...current,
...updates,
identity: {
...current.identity,
...(updates.identity || {})
},
seo: {
...current.seo,
...(updates.seo || {})
},
pwa: updates.pwa ? { ...current.pwa, ...updates.pwa } : current.pwa
});
}
/**
* Gets all config variables.
* @returns Array of config variables
*/
getConfigVariables() {
return this.getAppConfig().variables || [];
}
/**
* Sets or updates a config variable.
* @param variable - The config variable to set
*/
setConfigVariable(variable) {
const config = this.getAppConfig();
const index = config.variables.findIndex((v) => v.key === variable.key);
if (index >= 0) {
config.variables[index] = variable;
} else {
config.variables.push(variable);
}
this.setAppConfig(config);
}
/**
* Removes a config variable by key.
* @param key - The variable key to remove
*/
removeConfigVariable(key: string) {
const config = this.getAppConfig();
config.variables = config.variables.filter((v) => v.key !== key);
this.setAppConfig(config);
}
createNewVariant(name, node) {
let variant = this.variants.find((v) => v.name === name && v.typename === node.type.localName);

View File

@@ -8,6 +8,7 @@ import { IconName } from '@noodl-core-ui/components/common/Icon';
import config from '../../shared/config/config';
import { ComponentDiffDocumentProvider } from './views/documents/ComponentDiffDocument';
import { EditorDocumentProvider } from './views/documents/EditorDocument';
import { AppSetupPanel } from './views/panels/AppSetupPanel/AppSetupPanel';
import { BackendServicesPanel } from './views/panels/BackendServicesPanel/BackendServicesPanel';
import { CloudFunctionsPanel } from './views/panels/CloudFunctionsPanel/CloudFunctionsPanel';
import { CloudServicePanel } from './views/panels/CloudServicePanel/CloudServicePanel';
@@ -148,6 +149,15 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) {
panel: BackendServicesPanel
});
SidebarModel.instance.register({
id: 'app-setup',
name: 'App Setup',
isDisabled: isLesson === true,
order: 8.5,
icon: IconName.Sliders,
panel: AppSetupPanel
});
SidebarModel.instance.register({
id: 'settings',
name: 'Project settings',

View File

@@ -26,6 +26,13 @@ export interface createModelOptions {
* Create the Monaco Model, with better typings etc
*/
export function createModel(options: createModelOptions, node: NodeGraphNode): EditorModel {
// Simple JSON editing - use plaintext (no workers needed)
// Monaco workers require initialization from node context; plaintext avoids this.
// JSON validation happens on save via JSON.parse()
if (options.codeeditor === 'json') {
return new EditorModel(monaco.editor.createModel(options.value, 'plaintext'));
}
// arrays are edited as javascript (and eval:ed during runtime)
// we are not going to add any extra typings here.
if (options.type === 'array') {

View File

@@ -0,0 +1,70 @@
import { useEventListener } from '@noodl-hooks/useEventListener';
import React, { useState, useCallback } from 'react';
import { ProjectModel } from '@noodl-models/projectmodel';
import { BasePanel } from '@noodl-core-ui/components/sidebar/BasePanel';
import { IdentitySection } from './sections/IdentitySection';
import { PWASection } from './sections/PWASection';
import { SEOSection } from './sections/SEOSection';
import { VariablesSection } from './sections/VariablesSection';
export function AppSetupPanel() {
const [, forceUpdate] = useState(0);
// Listen for metadata changes to refresh the panel
useEventListener(
ProjectModel.instance,
'ProjectModel.metadataChanged',
useCallback((data: { key: string }) => {
if (data.key === 'appConfig') {
forceUpdate((prev) => prev + 1);
}
}, [])
);
const config = ProjectModel.instance.getAppConfig();
const updateIdentity = useCallback((updates: Partial<typeof config.identity>) => {
const currentConfig = ProjectModel.instance.getAppConfig();
ProjectModel.instance.updateAppConfig({
identity: { ...currentConfig.identity, ...updates }
});
}, []);
const updateSEO = useCallback((updates: Partial<typeof config.seo>) => {
const currentConfig = ProjectModel.instance.getAppConfig();
ProjectModel.instance.updateAppConfig({
seo: { ...currentConfig.seo, ...updates }
});
}, []);
const updatePWA = useCallback((updates: Partial<NonNullable<typeof config.pwa>>) => {
const currentConfig = ProjectModel.instance.getAppConfig();
ProjectModel.instance.updateAppConfig({
pwa: { ...(currentConfig.pwa || {}), ...updates } as NonNullable<typeof config.pwa>
});
}, []);
const updateVariables = useCallback((variables: typeof config.variables) => {
// Defer the update to avoid re-render race condition
setTimeout(() => {
ProjectModel.instance.updateAppConfig({
variables
});
}, 0);
}, []);
return (
<BasePanel title="App Setup" hasContentScroll>
<IdentitySection identity={config.identity} onChange={updateIdentity} />
<SEOSection seo={config.seo} identity={config.identity} onChange={updateSEO} />
<PWASection pwa={config.pwa} onChange={updatePWA} />
<VariablesSection variables={config.variables} onChange={updateVariables} />
</BasePanel>
);
}

View File

@@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react';
import { AppIdentity } from '@noodl/runtime/src/config/types';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
interface IdentitySectionProps {
identity: AppIdentity;
onChange: (updates: Partial<AppIdentity>) => void;
}
export function IdentitySection({ identity, onChange }: IdentitySectionProps) {
// Local state for immediate textarea updates
const [localDescription, setLocalDescription] = useState(identity.description || '');
// Sync local state when external identity changes
useEffect(() => {
setLocalDescription(identity.description || '');
}, [identity.description]);
return (
<CollapsableSection title="App Identity" hasGutter hasVisibleOverflow>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
App Name
</label>
<PropertyPanelTextInput value={identity.appName || ''} onChange={(value) => onChange({ appName: value })} />
</div>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Description
</label>
<textarea
value={localDescription}
onChange={(e) => {
setLocalDescription(e.target.value);
onChange({ description: e.target.value });
}}
placeholder="Describe your app..."
rows={3}
style={{
width: '100%',
padding: '8px',
fontSize: '13px',
fontFamily: 'inherit',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
color: 'var(--theme-color-fg-default)',
resize: 'vertical'
}}
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Cover Image
</label>
<PropertyPanelTextInput
value={identity.coverImage || ''}
onChange={(value) => onChange({ coverImage: value })}
/>
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginTop: '4px'
}}
>
Path to cover image in project
</div>
</div>
</CollapsableSection>
);
}

View File

@@ -0,0 +1,275 @@
import React, { useState, useEffect } from 'react';
import { AppPWA } from '@noodl/runtime/src/config/types';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
interface PWASectionProps {
pwa: AppPWA | undefined;
onChange: (updates: Partial<AppPWA>) => void;
}
const DISPLAY_MODES = [
{ value: 'standalone', label: 'Standalone (Recommended)' },
{ value: 'fullscreen', label: 'Fullscreen' },
{ value: 'minimal-ui', label: 'Minimal UI' },
{ value: 'browser', label: 'Browser' }
] as const;
export function PWASection({ pwa, onChange }: PWASectionProps) {
// Local state for immediate UI updates
const [localEnabled, setLocalEnabled] = useState(pwa?.enabled || false);
const [localDisplay, setLocalDisplay] = useState<AppPWA['display']>(pwa?.display || 'standalone');
const [localBgColor, setLocalBgColor] = useState(pwa?.backgroundColor || '#ffffff');
// Sync local state when external pwa changes
useEffect(() => {
setLocalEnabled(pwa?.enabled || false);
}, [pwa?.enabled]);
useEffect(() => {
setLocalDisplay(pwa?.display || 'standalone');
}, [pwa?.display]);
useEffect(() => {
setLocalBgColor(pwa?.backgroundColor || '#ffffff');
}, [pwa?.backgroundColor]);
const handleToggle = () => {
if (!localEnabled) {
// Enable with defaults
setLocalEnabled(true); // Immediate UI update
onChange({
enabled: true,
startUrl: '/',
display: 'standalone'
});
} else {
// Disable
setLocalEnabled(false); // Immediate UI update
onChange({ enabled: false });
}
};
return (
<CollapsableSection title="Progressive Web App" hasGutter hasVisibleOverflow hasTopDivider>
{/* Enable PWA Toggle */}
<div style={{ marginBottom: '16px' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
color: 'var(--theme-color-fg-default)'
}}
>
<input
type="checkbox"
checked={localEnabled}
onChange={handleToggle}
style={{
width: '16px',
height: '16px',
cursor: 'pointer'
}}
/>
Enable Progressive Web App
</label>
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginTop: '4px',
marginLeft: '24px'
}}
>
Allow users to install your app on their device
</div>
</div>
{/* PWA Configuration Fields - Only shown when enabled */}
{localEnabled && (
<>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Short Name
</label>
<PropertyPanelTextInput value={pwa?.shortName || ''} onChange={(value) => onChange({ shortName: value })} />
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginTop: '4px'
}}
>
Name shown on home screen (12 chars max recommended)
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Start URL
</label>
<PropertyPanelTextInput
value={pwa?.startUrl || '/'}
onChange={(value) => onChange({ startUrl: value || '/' })}
/>
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginTop: '4px'
}}
>
URL the app opens to (default: /)
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Display Mode
</label>
<select
value={localDisplay}
onChange={(e) => {
const newValue = e.target.value as AppPWA['display'];
setLocalDisplay(newValue);
onChange({ display: newValue });
}}
style={{
width: '100%',
padding: '8px',
fontSize: '13px',
fontFamily: 'inherit',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
color: 'var(--theme-color-fg-default)',
cursor: 'pointer'
}}
>
{DISPLAY_MODES.map((mode) => (
<option key={mode.value} value={mode.value}>
{mode.label}
</option>
))}
</select>
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginTop: '4px'
}}
>
How the app appears when launched
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Background Color
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="color"
value={localBgColor}
onChange={(e) => {
setLocalBgColor(e.target.value);
onChange({ backgroundColor: e.target.value });
}}
style={{
width: '40px',
height: '32px',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
cursor: 'pointer',
backgroundColor: 'transparent'
}}
/>
<div style={{ flex: 1 }}>
<PropertyPanelTextInput
value={localBgColor}
onChange={(value) => {
setLocalBgColor(value);
onChange({ backgroundColor: value });
}}
/>
</div>
</div>
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginTop: '4px'
}}
>
Splash screen background color
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Source Icon
</label>
<PropertyPanelTextInput
value={pwa?.sourceIcon || ''}
onChange={(value) => onChange({ sourceIcon: value })}
/>
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginTop: '4px'
}}
>
Path to 512x512 icon (generates all sizes automatically)
</div>
</div>
</>
)}
</CollapsableSection>
);
}

View File

@@ -0,0 +1,213 @@
import React, { useState, useEffect } from 'react';
import { AppSEO, AppIdentity } from '@noodl/runtime/src/config/types';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
interface SEOSectionProps {
seo: AppSEO;
identity: AppIdentity;
onChange: (updates: Partial<AppSEO>) => void;
}
export function SEOSection({ seo, identity, onChange }: SEOSectionProps) {
// Local state for immediate UI updates
const [localOgDescription, setLocalOgDescription] = useState(seo.ogDescription || '');
const [localThemeColor, setLocalThemeColor] = useState(seo.themeColor || '#000000');
// Sync local state when external seo changes
useEffect(() => {
setLocalOgDescription(seo.ogDescription || '');
}, [seo.ogDescription]);
useEffect(() => {
setLocalThemeColor(seo.themeColor || '#000000');
}, [seo.themeColor]);
// Smart defaults from identity
const defaultOgTitle = identity.appName || '';
const defaultOgDescription = identity.description || '';
const defaultOgImage = identity.coverImage || '';
return (
<CollapsableSection title="SEO & Metadata" hasGutter hasVisibleOverflow hasTopDivider>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Open Graph Title
</label>
<PropertyPanelTextInput
value={seo.ogTitle || ''}
onChange={(value) => onChange({ ogTitle: value || undefined })}
/>
{!seo.ogTitle && defaultOgTitle && (
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginTop: '4px'
}}
>
Defaults to: {defaultOgTitle}
</div>
)}
</div>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Open Graph Description
</label>
<textarea
value={localOgDescription}
onChange={(e) => {
setLocalOgDescription(e.target.value);
onChange({ ogDescription: e.target.value || undefined });
}}
placeholder="Enter description..."
rows={3}
style={{
width: '100%',
padding: '8px',
fontSize: '13px',
fontFamily: 'inherit',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
color: 'var(--theme-color-fg-default)',
resize: 'vertical'
}}
/>
{!seo.ogDescription && defaultOgDescription && (
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginTop: '4px'
}}
>
Defaults to: {defaultOgDescription.substring(0, 50)}
{defaultOgDescription.length > 50 ? '...' : ''}
</div>
)}
</div>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Open Graph Image
</label>
<PropertyPanelTextInput
value={seo.ogImage || ''}
onChange={(value) => onChange({ ogImage: value || undefined })}
/>
{!seo.ogImage && defaultOgImage && (
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginTop: '4px'
}}
>
Defaults to: {defaultOgImage}
</div>
)}
</div>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Favicon
</label>
<PropertyPanelTextInput value={seo.favicon || ''} onChange={(value) => onChange({ favicon: value })} />
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginTop: '4px'
}}
>
Path to favicon (.ico, .png, .svg)
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Theme Color
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="color"
value={localThemeColor}
onChange={(e) => {
setLocalThemeColor(e.target.value);
onChange({ themeColor: e.target.value });
}}
style={{
width: '40px',
height: '32px',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
cursor: 'pointer',
backgroundColor: 'transparent'
}}
/>
<div style={{ flex: 1 }}>
<PropertyPanelTextInput
value={localThemeColor}
onChange={(value) => {
setLocalThemeColor(value);
onChange({ themeColor: value });
}}
/>
</div>
</div>
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginTop: '4px'
}}
>
Browser theme color (hex format)
</div>
</div>
</CollapsableSection>
);
}

View File

@@ -0,0 +1,792 @@
import React, { useState, useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { ConfigVariable, ConfigType, RESERVED_CONFIG_KEYS } from '@noodl/runtime/src/config/types';
import { JSONEditor } from '@noodl-core-ui/components/json-editor';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
import PopupLayer from '../../../popuplayer';
interface VariablesSectionProps {
variables: ConfigVariable[];
onChange: (variables: ConfigVariable[]) => void;
}
// Separate component for JSON editor button that manages its own ref
interface JSONEditorButtonProps {
value: string;
varType: 'array' | 'object';
onSave: (value: string) => void;
}
function JSONEditorButton({ value, varType, onSave }: JSONEditorButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
const popoutRef = useRef<any>(null);
const rootRef = useRef<any>(null);
// Cleanup editor on unmount
useEffect(() => {
return () => {
if (rootRef.current) {
rootRef.current.unmount();
rootRef.current = null;
}
if (popoutRef.current) {
popoutRef.current = null;
}
};
}, []);
const openEditor = () => {
if (!buttonRef.current) {
return;
}
// Close any existing editor
if (rootRef.current) {
rootRef.current.unmount();
rootRef.current = null;
}
// Create popup container
const popupDiv = document.createElement('div');
const root = createRoot(popupDiv);
rootRef.current = root;
// Track current value for save
let currentValue = value;
const handleChange = (newValue: string) => {
currentValue = newValue;
};
const handleClose = () => {
try {
// Parse and validate before saving
const parsed = JSON.parse(currentValue);
if (varType === 'array' && !Array.isArray(parsed)) {
return;
}
if (varType === 'object' && (typeof parsed !== 'object' || Array.isArray(parsed))) {
return;
}
onSave(currentValue);
} catch {
// Invalid JSON - don't save
}
root.unmount();
rootRef.current = null;
};
root.render(
<div style={{ padding: '16px', width: '600px', height: '500px', display: 'flex', flexDirection: 'column' }}>
<JSONEditor value={value} onChange={handleChange} expectedType={varType} height="100%" />
</div>
);
const popout = PopupLayer.instance.showPopout({
content: { el: [popupDiv] },
attachTo: $(buttonRef.current),
position: 'right',
onClose: handleClose
});
popoutRef.current = popout;
};
return (
<button
ref={buttonRef}
onClick={(e) => {
e.stopPropagation();
openEditor();
}}
style={{
width: '100%',
padding: '10px',
fontSize: '13px',
fontFamily: 'monospace',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
color: 'var(--theme-color-fg-default)',
cursor: 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
color: 'var(--theme-color-fg-muted)'
}}
>
{value || (varType === 'array' ? '[]' : '{}')}
</span>
<span style={{ marginLeft: '8px', color: 'var(--theme-color-primary)' }}>Edit JSON </span>
</button>
);
}
const ALL_TYPES = [
{ value: 'string' as const, label: 'String' },
{ value: 'number' as const, label: 'Number' },
{ value: 'boolean' as const, label: 'Boolean' },
{ value: 'color' as const, label: 'Color' },
{ value: 'array' as const, label: 'Array' },
{ value: 'object' as const, label: 'Object' }
];
export function VariablesSection({ variables, onChange }: VariablesSectionProps) {
// Local state for optimistic updates
const [localVariables, setLocalVariables] = useState<ConfigVariable[]>(variables);
const [isAdding, setIsAdding] = useState(false);
// Sync with props when they change externally
useEffect(() => {
setLocalVariables(variables);
}, [variables]);
// New variable form state
const [newKey, setNewKey] = useState('');
const [newType, setNewType] = useState<ConfigType>('string');
const [newValue, setNewValue] = useState('');
const [newDescription, setNewDescription] = useState('');
const [newCategory, setNewCategory] = useState('');
const [error, setError] = useState('');
const [jsonError, setJsonError] = useState('');
const validateKey = (key: string, excludeIndex?: number): string | null => {
if (!key.trim()) {
return 'Key is required';
}
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
return 'Key must start with letter or underscore, and contain only letters, numbers, and underscores';
}
if (RESERVED_CONFIG_KEYS.includes(key as (typeof RESERVED_CONFIG_KEYS)[number])) {
return `"${key}" is a reserved key`;
}
const isDuplicate = localVariables.some((v, index) => v.key === key && index !== excludeIndex);
if (isDuplicate) {
return `Key "${key}" already exists`;
}
return null;
};
const parseValue = (type: ConfigType, valueStr: string): unknown => {
if (type === 'string') return valueStr;
if (type === 'number') {
const num = Number(valueStr);
return isNaN(num) ? 0 : num;
}
if (type === 'boolean') return valueStr === 'true';
if (type === 'color') return valueStr || '#000000';
if (type === 'array') {
try {
const parsed = JSON.parse(valueStr || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
if (type === 'object') {
try {
const parsed = JSON.parse(valueStr || '{}');
return typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
return valueStr;
};
const serializeValue = (variable: ConfigVariable): string => {
if (variable.type === 'string') return String(variable.value || '');
if (variable.type === 'number') return String(variable.value || 0);
if (variable.type === 'boolean') return String(variable.value);
if (variable.type === 'color') return String(variable.value || '#000000');
if (variable.type === 'array' || variable.type === 'object') {
try {
return JSON.stringify(variable.value, null, 2);
} catch {
return variable.type === 'array' ? '[]' : '{}';
}
}
return String(variable.value || '');
};
const handleAdd = () => {
const keyError = validateKey(newKey);
if (keyError) {
setError(keyError);
return;
}
// Validate JSON for array/object types
if ((newType === 'array' || newType === 'object') && newValue) {
try {
JSON.parse(newValue);
setJsonError('');
} catch {
setJsonError('Invalid JSON');
return;
}
}
const newVariable: ConfigVariable = {
key: newKey.trim(),
type: newType,
value: parseValue(newType, newValue),
description: newDescription.trim() || undefined,
category: newCategory.trim() || undefined
};
// Update local state immediately (optimistic update)
const updated = [...localVariables, newVariable];
setLocalVariables(updated);
// Persist in background
onChange(updated);
// Reset form
setNewKey('');
setNewType('string');
setNewValue('');
setNewDescription('');
setNewCategory('');
setError('');
setJsonError('');
setIsAdding(false);
};
const handleUpdate = (index: number, updates: Partial<ConfigVariable>) => {
// Update local state immediately (optimistic update)
const updated = [...localVariables];
updated[index] = { ...updated[index], ...updates };
setLocalVariables(updated);
// Persist in background
onChange(updated);
};
const handleDelete = (index: number) => {
// Update local state immediately (optimistic update)
const updated = localVariables.filter((_, i) => i !== index);
setLocalVariables(updated);
// Persist in background
onChange(updated);
};
const handleCancel = () => {
setIsAdding(false);
setNewKey('');
setNewType('string');
setNewValue('');
setNewDescription('');
setNewCategory('');
setError('');
setJsonError('');
};
const renderValueInput = (type: ConfigType, value: string, onValueChange: (v: string) => void): React.ReactNode => {
if (type === 'boolean') {
return (
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
fontSize: '13px',
color: 'var(--theme-color-fg-default)'
}}
>
<input
type="checkbox"
checked={value === 'true'}
onChange={(e) => onValueChange(e.target.checked ? 'true' : 'false')}
style={{
width: '16px',
height: '16px',
cursor: 'pointer'
}}
/>
Enabled
</label>
);
}
if (type === 'number') {
return (
<input
type="number"
value={value || ''}
onChange={(e) => onValueChange(e.target.value)}
placeholder="0"
style={{
width: '100%',
padding: '8px',
fontSize: '13px',
fontFamily: 'inherit',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
color: 'var(--theme-color-fg-default)'
}}
/>
);
}
if (type === 'color') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="color"
value={value || '#000000'}
onChange={(e) => onValueChange(e.target.value)}
style={{
width: '40px',
height: '32px',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
cursor: 'pointer',
backgroundColor: 'transparent'
}}
/>
<div style={{ flex: 1 }}>
<PropertyPanelTextInput value={value || '#000000'} onChange={onValueChange} />
</div>
</div>
);
}
if (type === 'array' || type === 'object') {
return (
<JSONEditorButton
value={value || (type === 'array' ? '[]' : '{}')}
varType={type}
onSave={(newValue) => {
onValueChange(newValue);
setJsonError('');
}}
/>
);
}
return <PropertyPanelTextInput value={value} onChange={onValueChange} />;
};
// Group variables by category
const groupedVariables: { [category: string]: ConfigVariable[] } = {};
localVariables.forEach((variable) => {
const cat = variable.category || 'Uncategorized';
if (!groupedVariables[cat]) {
groupedVariables[cat] = [];
}
groupedVariables[cat].push(variable);
});
const categories = Object.keys(groupedVariables).sort((a, b) => {
// Uncategorized always first
if (a === 'Uncategorized') return -1;
if (b === 'Uncategorized') return 1;
return a.localeCompare(b);
});
return (
<CollapsableSection title="Custom Variables" hasGutter hasVisibleOverflow hasTopDivider>
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
marginBottom: '12px'
}}
>
Define custom config variables accessible via Noodl.Config.get(&apos;key&apos;)
</div>
{/* Add Variable Button + Clear All */}
{!isAdding && (
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
<button
onClick={() => setIsAdding(true)}
style={{
flex: 1,
padding: '8px',
fontSize: '13px',
fontWeight: 500,
backgroundColor: 'var(--theme-color-primary)',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
+ Add Variable
</button>
{localVariables.length > 0 && (
<button
onClick={() => {
if (window.confirm(`Delete all ${localVariables.length} variables?`)) {
setLocalVariables([]);
onChange([]);
}
}}
style={{
padding: '8px 12px',
fontSize: '13px',
fontWeight: 500,
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Clear All
</button>
)}
</div>
)}
{/* Add Variable Form */}
{isAdding && (
<div
style={{
padding: '12px',
marginBottom: '12px',
backgroundColor: 'var(--theme-color-bg-2)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px'
}}
>
<div style={{ marginBottom: '8px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Key *
</label>
<PropertyPanelTextInput
value={newKey}
onChange={(value) => {
setNewKey(value);
setError('');
}}
/>
</div>
<div style={{ marginBottom: '8px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Type
</label>
<select
value={newType}
onChange={(e) => {
const newT = e.target.value as ConfigType;
setNewType(newT);
// Set appropriate default values
if (newT === 'boolean') setNewValue('false');
else if (newT === 'number') setNewValue('0');
else if (newT === 'color') setNewValue('#000000');
else if (newT === 'array') setNewValue('[]');
else if (newT === 'object') setNewValue('{}');
else setNewValue('');
setJsonError('');
}}
style={{
width: '100%',
padding: '8px',
fontSize: '13px',
fontFamily: 'inherit',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
color: 'var(--theme-color-fg-default)',
cursor: 'pointer'
}}
>
{ALL_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div style={{ marginBottom: '8px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Value
</label>
{renderValueInput(newType, newValue, setNewValue)}
</div>
<div style={{ marginBottom: '8px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Category (optional)
</label>
<PropertyPanelTextInput value={newCategory} onChange={setNewCategory} />
</div>
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-default)'
}}
>
Description
</label>
<PropertyPanelTextInput value={newDescription} onChange={setNewDescription} />
</div>
{error && (
<div
style={{
padding: '8px',
marginBottom: '8px',
fontSize: '12px',
color: 'var(--theme-color-error)',
backgroundColor: 'var(--theme-color-error-bg)',
border: '1px solid var(--theme-color-error)',
borderRadius: '4px'
}}
>
{error}
</div>
)}
{jsonError && (
<div
style={{
padding: '8px',
marginBottom: '8px',
fontSize: '12px',
color: 'var(--theme-color-error)',
backgroundColor: 'var(--theme-color-error-bg)',
border: '1px solid var(--theme-color-error)',
borderRadius: '4px'
}}
>
{jsonError}
</div>
)}
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleAdd}
disabled={!!error || !!jsonError}
style={{
flex: 1,
padding: '8px',
fontSize: '13px',
fontWeight: 500,
backgroundColor: error || jsonError ? 'var(--theme-color-bg-3)' : 'var(--theme-color-primary)',
color: error || jsonError ? 'var(--theme-color-fg-muted)' : 'white',
border: 'none',
borderRadius: '4px',
cursor: error || jsonError ? 'not-allowed' : 'pointer'
}}
>
Add
</button>
<button
onClick={handleCancel}
style={{
flex: 1,
padding: '8px',
fontSize: '13px',
fontWeight: 500,
backgroundColor: 'var(--theme-color-bg-3)',
color: 'var(--theme-color-fg-default)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Cancel
</button>
</div>
</div>
)}
{/* Variables List - Grouped by Category */}
{localVariables.length === 0 && !isAdding && (
<div
style={{
padding: '16px',
textAlign: 'center',
fontSize: '13px',
color: 'var(--theme-color-fg-muted)',
backgroundColor: 'var(--theme-color-bg-2)',
border: '1px dashed var(--theme-color-border-default)',
borderRadius: '4px'
}}
>
No variables defined yet
</div>
)}
{categories.map((category) => (
<div key={category} style={{ marginBottom: '16px' }}>
{categories.length > 1 && (
<div
style={{
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
color: 'var(--theme-color-fg-muted)',
marginBottom: '8px',
letterSpacing: '0.5px'
}}
>
{category}
</div>
)}
{groupedVariables[category].map((variable) => {
const index = localVariables.indexOf(variable);
return (
<div
key={index}
style={{
padding: '12px',
marginBottom: '8px',
backgroundColor: 'var(--theme-color-bg-2)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px'
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
<span
style={{
fontSize: '13px',
fontWeight: 500,
fontFamily: 'monospace',
color: 'var(--theme-color-fg-default)'
}}
>
{variable.key}
</span>
<span
style={{
fontSize: '11px',
padding: '2px 6px',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '3px',
color: 'var(--theme-color-fg-muted)'
}}
>
{variable.type}
</span>
</div>
<button
onClick={() => handleDelete(index)}
title="Delete variable"
style={{
padding: '4px 10px',
fontSize: '16px',
fontWeight: 'bold',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
minWidth: '28px',
lineHeight: 1
}}
>
</button>
</div>
<div style={{ marginBottom: '8px' }}>
<label
style={{
display: 'block',
fontSize: '11px',
fontWeight: 500,
marginBottom: '4px',
color: 'var(--theme-color-fg-muted)'
}}
>
Value
</label>
{renderValueInput(variable.type, serializeValue(variable), (value) =>
handleUpdate(index, { value: parseValue(variable.type, value) })
)}
</div>
{variable.description && (
<div
style={{
fontSize: '11px',
color: 'var(--theme-color-fg-muted)',
fontStyle: 'italic',
marginTop: '4px'
}}
>
{variable.description}
</div>
)}
</div>
);
})}
</div>
))}
</CollapsableSection>
);
}

View File

@@ -8,7 +8,7 @@ module.exports = merge(
merge(shared, {
entry: {
'./src/editor/index': './src/editor/index.ts',
'./src/frames/viewer-frame/index': './src/frames/viewer-frame/index.js',
'./src/frames/viewer-frame/index': './src/frames/viewer-frame/index.js'
},
output: {
filename: '[name].bundle.js',
@@ -17,7 +17,9 @@ module.exports = merge(
},
plugins: [
new MonacoWebpackPlugin({
languages: ['typescript', 'javascript', 'css']
// JSON language worker is broken in Electron/CommonJS - use TypeScript for JSON editing
languages: ['typescript', 'javascript', 'css'],
globalAPI: true
})
]
})

View File

@@ -0,0 +1,226 @@
/**
* Tests for ConfigManager
*/
import { describe, it, expect, beforeEach } from '@jest/globals';
import { ConfigManager } from './config-manager';
import { DEFAULT_APP_CONFIG } from './types';
describe('ConfigManager', () => {
let manager: ConfigManager;
beforeEach(() => {
manager = ConfigManager.getInstance();
manager.reset(); // Reset state between tests
});
describe('singleton pattern', () => {
it('should return the same instance', () => {
const instance1 = ConfigManager.getInstance();
const instance2 = ConfigManager.getInstance();
expect(instance1).toBe(instance2);
});
});
describe('initialize', () => {
it('should initialize with default config', () => {
manager.initialize({});
const config = manager.getRawConfig();
expect(config.identity.appName).toBe(DEFAULT_APP_CONFIG.identity.appName);
});
it('should merge partial config with defaults', () => {
manager.initialize({
identity: {
appName: 'My App',
description: 'Test'
}
});
const config = manager.getRawConfig();
expect(config.identity.appName).toBe('My App');
expect(config.variables).toEqual([]);
});
it('should store custom variables', () => {
manager.initialize({
identity: DEFAULT_APP_CONFIG.identity,
seo: {},
variables: [{ key: 'apiKey', type: 'string', value: 'secret123' }]
});
const config = manager.getRawConfig();
expect(config.variables.length).toBe(1);
expect(config.variables[0].key).toBe('apiKey');
});
});
describe('getConfig', () => {
it('should return flattened config object', () => {
manager.initialize({
identity: {
appName: 'Test App',
description: 'Test Description'
},
seo: {},
variables: [
{ key: 'apiKey', type: 'string', value: 'key123' },
{ key: 'maxRetries', type: 'number', value: 3 }
]
});
const config = manager.getConfig();
expect(config.appName).toBe('Test App');
expect(config.description).toBe('Test Description');
expect(config.apiKey).toBe('key123');
expect(config.maxRetries).toBe(3);
});
it('should apply smart defaults for SEO', () => {
manager.initialize({
identity: {
appName: 'Test App',
description: 'Test Description',
coverImage: 'cover.jpg'
},
seo: {}, // Empty SEO
variables: []
});
const config = manager.getConfig();
// Should default to identity values
expect(config.ogTitle).toBe('Test App');
expect(config.ogDescription).toBe('Test Description');
expect(config.ogImage).toBe('cover.jpg');
});
it('should use explicit SEO values when provided', () => {
manager.initialize({
identity: {
appName: 'Test App',
description: 'Test Description'
},
seo: {
ogTitle: 'Custom OG Title',
ogDescription: 'Custom OG Description'
},
variables: []
});
const config = manager.getConfig();
expect(config.ogTitle).toBe('Custom OG Title');
expect(config.ogDescription).toBe('Custom OG Description');
});
it('should return frozen object', () => {
manager.initialize({
identity: DEFAULT_APP_CONFIG.identity,
seo: {},
variables: []
});
const config = manager.getConfig();
expect(Object.isFrozen(config)).toBe(true);
// Attempt to modify should fail silently (or throw in strict mode)
expect(() => {
(config as any).appName = 'Modified';
}).toThrow();
});
});
describe('getVariable', () => {
beforeEach(() => {
manager.initialize({
identity: DEFAULT_APP_CONFIG.identity,
seo: {},
variables: [
{ key: 'apiKey', type: 'string', value: 'key123', description: 'API Key' },
{ key: 'maxRetries', type: 'number', value: 3 }
]
});
});
it('should return variable by key', () => {
const variable = manager.getVariable('apiKey');
expect(variable).toBeDefined();
expect(variable?.value).toBe('key123');
expect(variable?.description).toBe('API Key');
});
it('should return undefined for non-existent key', () => {
const variable = manager.getVariable('nonExistent');
expect(variable).toBeUndefined();
});
});
describe('getVariableKeys', () => {
it('should return all variable keys', () => {
manager.initialize({
identity: DEFAULT_APP_CONFIG.identity,
seo: {},
variables: [
{ key: 'apiKey', type: 'string', value: 'key123' },
{ key: 'maxRetries', type: 'number', value: 3 },
{ key: 'enabled', type: 'boolean', value: true }
]
});
const keys = manager.getVariableKeys();
expect(keys).toEqual(['apiKey', 'maxRetries', 'enabled']);
});
it('should return empty array when no variables', () => {
manager.initialize({
identity: DEFAULT_APP_CONFIG.identity,
seo: {},
variables: []
});
const keys = manager.getVariableKeys();
expect(keys).toEqual([]);
});
});
describe('immutability', () => {
it('should not allow mutation of returned config', () => {
manager.initialize({
identity: {
appName: 'Original',
description: ''
},
seo: {},
variables: [{ key: 'apiKey', type: 'string', value: 'original' }]
});
const config1 = manager.getConfig();
// Attempt mutations should fail
expect(() => {
(config1 as any).apiKey = 'modified';
}).toThrow();
// Getting config again should still have original value
const config2 = manager.getConfig();
expect(config2.apiKey).toBe('original');
});
});
describe('reset', () => {
it('should reset to default state', () => {
manager.initialize({
identity: {
appName: 'Custom App',
description: 'Custom'
},
seo: {},
variables: [{ key: 'test', type: 'string', value: 'value' }]
});
manager.reset();
const config = manager.getRawConfig();
expect(config.identity.appName).toBe(DEFAULT_APP_CONFIG.identity.appName);
expect(config.variables).toEqual([]);
});
});
});

View File

@@ -0,0 +1,167 @@
/**
* Configuration Manager
*
* Central manager for app configuration. Initializes config at app startup
* and provides immutable access to configuration values via Noodl.Config.
*
* @module config/config-manager
*/
import { AppConfig, ConfigVariable, DEFAULT_APP_CONFIG } from './types';
/**
* Deep freezes an object recursively to prevent any mutation.
*/
function deepFreeze<T extends object>(obj: T): Readonly<T> {
Object.keys(obj).forEach((key) => {
const value = (obj as Record<string, unknown>)[key];
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
deepFreeze(value as object);
}
});
return Object.freeze(obj);
}
/**
* ConfigManager is a singleton that manages the app configuration.
* It is initialized once at app startup with config from project metadata.
*/
class ConfigManager {
private static instance: ConfigManager;
private config: AppConfig = DEFAULT_APP_CONFIG;
private frozenConfig: Readonly<Record<string, unknown>> | null = null;
private constructor() {
// Private constructor for singleton
}
/**
* Gets the singleton instance.
*/
static getInstance(): ConfigManager {
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager();
}
return ConfigManager.instance;
}
/**
* Initializes the configuration from project metadata.
* This should be called once at app startup.
*
* @param config - Partial app config from project.json metadata
*/
initialize(config: Partial<AppConfig>): void {
// Merge with defaults
this.config = {
...DEFAULT_APP_CONFIG,
...config,
identity: {
...DEFAULT_APP_CONFIG.identity,
...config.identity
},
seo: {
...DEFAULT_APP_CONFIG.seo,
...config.seo
},
variables: config.variables || []
};
// Build and freeze the public config object
this.frozenConfig = this.buildFrozenConfig();
}
/**
* Gets the immutable public configuration object.
* This is what's exposed as Noodl.Config.
*
* @returns Frozen config object with all values
*/
getConfig(): Readonly<Record<string, unknown>> {
if (!this.frozenConfig) {
this.frozenConfig = this.buildFrozenConfig();
}
return this.frozenConfig;
}
/**
* Gets the raw configuration object (for editor use).
* This includes the full structure with identity, seo, pwa, and variables.
*
* @returns The full app config object
*/
getRawConfig(): AppConfig {
return this.config;
}
/**
* Gets a specific variable definition by key.
*
* @param key - The variable key
* @returns The variable definition or undefined if not found
*/
getVariable(key: string): ConfigVariable | undefined {
return this.config.variables.find((v) => v.key === key);
}
/**
* Gets all variable keys (for autocomplete/suggestions).
*
* @returns Array of all custom variable keys
*/
getVariableKeys(): string[] {
return this.config.variables.map((v) => v.key);
}
/**
* Builds the flat, frozen config object exposed as Noodl.Config.
* This flattens identity, SEO, PWA, and custom variables into a single object.
*
* @returns The frozen config object
*/
private buildFrozenConfig(): Readonly<Record<string, unknown>> {
const config: Record<string, unknown> = {
// Identity fields
appName: this.config.identity.appName,
description: this.config.identity.description,
coverImage: this.config.identity.coverImage,
// SEO fields (with smart defaults)
ogTitle: this.config.seo.ogTitle || this.config.identity.appName,
ogDescription: this.config.seo.ogDescription || this.config.identity.description,
ogImage: this.config.seo.ogImage || this.config.identity.coverImage,
favicon: this.config.seo.favicon,
themeColor: this.config.seo.themeColor,
// PWA fields
pwaEnabled: this.config.pwa?.enabled ?? false,
pwaShortName: this.config.pwa?.shortName,
pwaDisplay: this.config.pwa?.display,
pwaStartUrl: this.config.pwa?.startUrl,
pwaBackgroundColor: this.config.pwa?.backgroundColor
};
// Add custom variables
for (const variable of this.config.variables) {
config[variable.key] = variable.value;
}
// Deep freeze to prevent any mutation
return deepFreeze(config);
}
/**
* Resets the config manager (primarily for testing).
* @internal
*/
reset(): void {
this.config = DEFAULT_APP_CONFIG;
this.frozenConfig = null;
}
}
// Export singleton instance
export const configManager = ConfigManager.getInstance();
// Export class for testing
export { ConfigManager };

View File

@@ -0,0 +1,11 @@
/**
* App Configuration Module
*
* Provides app-wide configuration accessible via Noodl.Config namespace.
*
* @module config
*/
export * from './types';
export * from './validation';
export * from './config-manager';

View File

@@ -0,0 +1,91 @@
/**
* App Configuration Types
*
* Defines the structure for app-wide configuration values accessible via Noodl.Config.
* Config values are static and immutable at runtime.
*
* @module config/types
*/
export type ConfigType = 'string' | 'number' | 'boolean' | 'color' | 'array' | 'object';
export interface ConfigValidation {
required?: boolean;
pattern?: string; // Regex for strings
min?: number; // For numbers
max?: number; // For numbers
}
export interface ConfigVariable {
key: string;
type: ConfigType;
value: unknown;
description?: string;
category?: string;
validation?: ConfigValidation;
}
export interface AppIdentity {
appName: string;
description: string;
coverImage?: string;
}
export interface AppSEO {
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
favicon?: string;
themeColor?: string;
}
export interface AppPWA {
enabled: boolean;
shortName?: string;
startUrl: string;
display: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
backgroundColor?: string;
sourceIcon?: string;
}
export interface AppConfig {
identity: AppIdentity;
seo: AppSEO;
pwa?: AppPWA;
variables: ConfigVariable[];
}
/**
* Default app configuration used when no config is defined.
*/
export const DEFAULT_APP_CONFIG: AppConfig = {
identity: {
appName: 'My Noodl App',
description: ''
},
seo: {},
variables: []
};
/**
* Reserved configuration keys that cannot be used for custom variables.
* These are populated from identity, SEO, and PWA settings.
*/
export const RESERVED_CONFIG_KEYS = [
// Identity
'appName',
'description',
'coverImage',
// SEO
'ogTitle',
'ogDescription',
'ogImage',
'favicon',
'themeColor',
// PWA
'pwaEnabled',
'pwaShortName',
'pwaDisplay',
'pwaStartUrl',
'pwaBackgroundColor'
] as const;

View File

@@ -0,0 +1,197 @@
/**
* Tests for Config Validation
*/
import { describe, it, expect } from '@jest/globals';
import { DEFAULT_APP_CONFIG } from './types';
import { validateConfigKey, validateConfigValue, validateAppConfig } from './validation';
describe('validateConfigKey', () => {
it('should accept valid JavaScript identifiers', () => {
expect(validateConfigKey('apiKey').valid).toBe(true);
expect(validateConfigKey('API_KEY').valid).toBe(true);
expect(validateConfigKey('_privateKey').valid).toBe(true);
expect(validateConfigKey('$special').valid).toBe(true);
expect(validateConfigKey('key123').valid).toBe(true);
});
it('should reject empty keys', () => {
const result = validateConfigKey('');
expect(result.valid).toBe(false);
expect(result.errors[0]).toContain('cannot be empty');
});
it('should reject invalid identifiers', () => {
expect(validateConfigKey('123key').valid).toBe(false);
expect(validateConfigKey('my-key').valid).toBe(false);
expect(validateConfigKey('my key').valid).toBe(false);
expect(validateConfigKey('my.key').valid).toBe(false);
});
it('should reject reserved keys', () => {
const result = validateConfigKey('appName');
expect(result.valid).toBe(false);
expect(result.errors[0]).toContain('reserved');
});
});
describe('validateConfigValue', () => {
describe('number type', () => {
it('should accept valid numbers', () => {
expect(validateConfigValue(42, 'number').valid).toBe(true);
expect(validateConfigValue(0, 'number').valid).toBe(true);
expect(validateConfigValue(-10.5, 'number').valid).toBe(true);
});
it('should reject non-numbers', () => {
expect(validateConfigValue('42', 'number').valid).toBe(false);
expect(validateConfigValue(NaN, 'number').valid).toBe(false);
});
it('should enforce min/max validation', () => {
expect(validateConfigValue(5, 'number', { min: 0, max: 10 }).valid).toBe(true);
expect(validateConfigValue(-1, 'number', { min: 0 }).valid).toBe(false);
expect(validateConfigValue(11, 'number', { max: 10 }).valid).toBe(false);
});
});
describe('string type', () => {
it('should accept valid strings', () => {
expect(validateConfigValue('hello', 'string').valid).toBe(true);
expect(validateConfigValue('', 'string').valid).toBe(true);
});
it('should reject non-strings', () => {
expect(validateConfigValue(42, 'string').valid).toBe(false);
});
it('should enforce pattern validation', () => {
const result = validateConfigValue('abc', 'string', { pattern: '^[0-9]+$' });
expect(result.valid).toBe(false);
const validResult = validateConfigValue('123', 'string', { pattern: '^[0-9]+$' });
expect(validResult.valid).toBe(true);
});
});
describe('boolean type', () => {
it('should accept booleans', () => {
expect(validateConfigValue(true, 'boolean').valid).toBe(true);
expect(validateConfigValue(false, 'boolean').valid).toBe(true);
});
it('should reject non-booleans', () => {
expect(validateConfigValue('true', 'boolean').valid).toBe(false);
expect(validateConfigValue(1, 'boolean').valid).toBe(false);
});
});
describe('color type', () => {
it('should accept valid hex colors', () => {
expect(validateConfigValue('#ff0000', 'color').valid).toBe(true);
expect(validateConfigValue('#FF0000', 'color').valid).toBe(true);
expect(validateConfigValue('#ff0000ff', 'color').valid).toBe(true);
});
it('should reject invalid colors', () => {
expect(validateConfigValue('red', 'color').valid).toBe(false);
expect(validateConfigValue('#ff00', 'color').valid).toBe(false);
expect(validateConfigValue('ff0000', 'color').valid).toBe(false);
});
});
describe('array type', () => {
it('should accept arrays', () => {
expect(validateConfigValue([], 'array').valid).toBe(true);
expect(validateConfigValue([1, 2, 3], 'array').valid).toBe(true);
});
it('should reject non-arrays', () => {
expect(validateConfigValue('[]', 'array').valid).toBe(false);
expect(validateConfigValue({}, 'array').valid).toBe(false);
});
});
describe('object type', () => {
it('should accept objects', () => {
expect(validateConfigValue({}, 'object').valid).toBe(true);
expect(validateConfigValue({ key: 'value' }, 'object').valid).toBe(true);
});
it('should reject non-objects', () => {
expect(validateConfigValue([], 'object').valid).toBe(false);
expect(validateConfigValue(null, 'object').valid).toBe(false);
expect(validateConfigValue('{}', 'object').valid).toBe(false);
});
});
describe('required validation', () => {
it('should require non-empty values', () => {
expect(validateConfigValue('', 'string', { required: true }).valid).toBe(false);
expect(validateConfigValue(null, 'string', { required: true }).valid).toBe(false);
expect(validateConfigValue(undefined, 'string', { required: true }).valid).toBe(false);
});
it('should allow empty values when not required', () => {
expect(validateConfigValue('', 'string').valid).toBe(true);
expect(validateConfigValue(null, 'string').valid).toBe(true);
});
});
});
describe('validateAppConfig', () => {
it('should accept valid config', () => {
const config = {
...DEFAULT_APP_CONFIG,
identity: {
appName: 'Test App',
description: 'Test Description'
}
};
expect(validateAppConfig(config).valid).toBe(true);
});
it('should require app name', () => {
const config = {
...DEFAULT_APP_CONFIG,
identity: {
appName: '',
description: ''
}
};
const result = validateAppConfig(config);
expect(result.valid).toBe(false);
expect(result.errors[0]).toContain('App name');
});
it('should detect duplicate variable keys', () => {
const config = {
...DEFAULT_APP_CONFIG,
identity: { appName: 'Test', description: '' },
variables: [
{ key: 'apiKey', type: 'string' as const, value: 'key1' },
{ key: 'apiKey', type: 'string' as const, value: 'key2' }
]
};
const result = validateAppConfig(config);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('Duplicate'))).toBe(true);
});
it('should validate individual variables', () => {
const config = {
...DEFAULT_APP_CONFIG,
identity: { appName: 'Test', description: '' },
variables: [{ key: 'my-invalid-key', type: 'string' as const, value: 'test' }]
};
const result = validateAppConfig(config);
expect(result.valid).toBe(false);
});
it('should reject non-object configs', () => {
const result = validateAppConfig(null);
expect(result.valid).toBe(false);
expect(result.errors[0]).toContain('must be an object');
});
});

View File

@@ -0,0 +1,179 @@
/**
* App Configuration Validation
*
* Provides validation utilities for config keys and values.
*
* @module config/validation
*/
import { ConfigType, ConfigValidation, RESERVED_CONFIG_KEYS } from './types';
export interface ValidationResult {
valid: boolean;
errors: string[];
}
/**
* Validates a configuration variable key.
* Keys must be valid JavaScript identifiers and not reserved.
*
* @param key - The key to validate
* @returns Validation result with errors if invalid
*/
export function validateConfigKey(key: string): ValidationResult {
const errors: string[] = [];
if (!key || key.trim() === '') {
errors.push('Key cannot be empty');
return { valid: false, errors };
}
// Must be valid JS identifier
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
errors.push('Key must be a valid JavaScript identifier (letters, numbers, _, $ only; cannot start with a number)');
}
// Check reserved keys
if ((RESERVED_CONFIG_KEYS as readonly string[]).includes(key)) {
errors.push(`"${key}" is a reserved configuration key`);
}
return { valid: errors.length === 0, errors };
}
/**
* Validates a configuration value based on its type and validation rules.
*
* @param value - The value to validate
* @param type - The expected type
* @param validation - Optional validation rules
* @returns Validation result with errors if invalid
*/
export function validateConfigValue(value: unknown, type: ConfigType, validation?: ConfigValidation): ValidationResult {
const errors: string[] = [];
// Required check
if (validation?.required && (value === undefined || value === null || value === '')) {
errors.push('This field is required');
return { valid: false, errors };
}
// If value is empty and not required, skip type validation
if (value === undefined || value === null || value === '') {
return { valid: true, errors: [] };
}
// Type-specific validation
switch (type) {
case 'number':
if (typeof value !== 'number' || isNaN(value)) {
errors.push('Value must be a number');
} else {
if (validation?.min !== undefined && value < validation.min) {
errors.push(`Value must be at least ${validation.min}`);
}
if (validation?.max !== undefined && value > validation.max) {
errors.push(`Value must be at most ${validation.max}`);
}
}
break;
case 'string':
if (typeof value !== 'string') {
errors.push('Value must be a string');
} else if (validation?.pattern) {
try {
const regex = new RegExp(validation.pattern);
if (!regex.test(value)) {
errors.push('Value does not match required pattern');
}
} catch (e) {
errors.push('Invalid validation pattern');
}
}
break;
case 'boolean':
if (typeof value !== 'boolean') {
errors.push('Value must be true or false');
}
break;
case 'color':
if (typeof value !== 'string') {
errors.push('Color value must be a string');
} else if (!/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(value)) {
errors.push('Value must be a valid hex color (e.g., #ff0000 or #ff0000ff)');
}
break;
case 'array':
if (!Array.isArray(value)) {
errors.push('Value must be an array');
}
break;
case 'object':
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
errors.push('Value must be an object');
}
break;
default:
errors.push(`Unknown type: ${type}`);
}
return { valid: errors.length === 0, errors };
}
/**
* Validates an entire app config object.
*
* @param config - The app config to validate
* @returns Validation result with all errors found
*/
export function validateAppConfig(config: unknown): ValidationResult {
const errors: string[] = [];
// Type guard for config object
if (!config || typeof config !== 'object') {
errors.push('Config must be an object');
return { valid: false, errors };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cfg = config as Record<string, any>;
// Check required identity fields
if (!cfg.identity?.appName || cfg.identity.appName.trim() === '') {
errors.push('App name is required');
}
// Validate custom variables
if (cfg.variables && Array.isArray(cfg.variables)) {
const seenKeys = new Set<string>();
for (const variable of cfg.variables) {
// Check for duplicate keys
if (seenKeys.has(variable.key)) {
errors.push(`Duplicate variable key: ${variable.key}`);
continue;
}
seenKeys.add(variable.key);
// Validate key
const keyValidation = validateConfigKey(variable.key);
if (!keyValidation.valid) {
errors.push(...keyValidation.errors.map((e) => `Variable "${variable.key}": ${e}`));
}
// Validate value
const valueValidation = validateConfigValue(variable.value, variable.type, variable.validation);
if (!valueValidation.valid) {
errors.push(...valueValidation.errors.map((e) => `Variable "${variable.key}": ${e}`));
}
}
}
return { valid: errors.length === 0, errors };
}

View File

@@ -0,0 +1,130 @@
/**
* Noodl.Config API
*
* Creates the immutable Noodl.Config proxy object that provides access
* to app-wide configuration values defined in App Setup.
*
* @module api/config
*/
import { AppConfig, DEFAULT_APP_CONFIG } from '@noodl/runtime/src/config/types';
/**
* Builds the flat config object exposed as Noodl.Config.
* This flattens identity, SEO, PWA, and custom variables into a single object.
*/
function buildFlatConfig(appConfig: Partial<AppConfig> | undefined): Record<string, unknown> {
if (!appConfig) {
appConfig = {};
}
const config = {
...DEFAULT_APP_CONFIG,
...appConfig,
identity: {
...DEFAULT_APP_CONFIG.identity,
...appConfig.identity
},
seo: {
...DEFAULT_APP_CONFIG.seo,
...appConfig.seo
},
variables: appConfig.variables || []
};
const flat: Record<string, unknown> = {
// Identity fields
appName: config.identity.appName,
description: config.identity.description,
coverImage: config.identity.coverImage,
// SEO fields (with smart defaults)
ogTitle: config.seo.ogTitle || config.identity.appName,
ogDescription: config.seo.ogDescription || config.identity.description,
ogImage: config.seo.ogImage || config.identity.coverImage,
favicon: config.seo.favicon,
themeColor: config.seo.themeColor,
// PWA fields
pwaEnabled: config.pwa?.enabled ?? false,
pwaShortName: config.pwa?.shortName,
pwaDisplay: config.pwa?.display,
pwaStartUrl: config.pwa?.startUrl,
pwaBackgroundColor: config.pwa?.backgroundColor
};
// Add custom variables
for (const variable of config.variables) {
flat[variable.key] = variable.value;
}
return flat;
}
/**
* Creates the Noodl.Config proxy object.
* Returns an immutable object that throws helpful errors on write attempts.
*
* Uses lazy evaluation via getMetaData to handle async metadata loading.
*
* @param getMetaData - Function to get metadata by key (from noodlRuntime)
* @returns Readonly proxy to the configuration object
*/
export function createConfigAPI(getMetaData: (key: string) => unknown): Readonly<Record<string, unknown>> {
const getConfig = (): Record<string, unknown> => {
// Rebuild config on each access to handle late-loaded metadata
const appConfig = getMetaData('appConfig') as Partial<AppConfig> | undefined;
return buildFlatConfig(appConfig);
};
return new Proxy({} as Record<string, unknown>, {
get(_target, prop: string | symbol) {
// Handle special Proxy methods
if (typeof prop === 'symbol') {
return undefined;
}
const config = getConfig();
if (prop in config) {
return config[prop];
}
console.warn(`Noodl.Config.${prop} is not defined`);
return undefined;
},
has(_target, prop: string) {
const config = getConfig();
return prop in config;
},
ownKeys() {
const config = getConfig();
return Reflect.ownKeys(config);
},
getOwnPropertyDescriptor(_target, prop: string) {
const config = getConfig();
if (prop in config) {
return {
enumerable: true,
configurable: true,
value: config[prop]
};
}
return undefined;
},
set(_target, prop: string) {
console.error(
`Cannot set Noodl.Config.${prop} - Config values are immutable. ` +
`Use Noodl.Variables for runtime-changeable values.`
);
return false;
},
deleteProperty(_target, prop: string) {
console.error(`Cannot delete Noodl.Config.${prop} - Config values are immutable.`);
return false;
}
});
}

View File

@@ -1,5 +1,6 @@
'use strict';
import { createConfigAPI } from './api/config';
import { SeoApi } from './api/seo';
export default function createNoodlAPI(noodlRuntime) {
@@ -8,6 +9,7 @@ export default function createNoodlAPI(noodlRuntime) {
global.Noodl.getProjectSettings = noodlRuntime.getProjectSettings.bind(noodlRuntime);
global.Noodl.getMetaData = noodlRuntime.getMetaData.bind(noodlRuntime);
global.Noodl.Collection = global.Noodl.Array = require('@noodl/runtime/src/collection');
global.Noodl.Model = global.Noodl.Object = require('@noodl/runtime/src/model');
global.Noodl.Variables = global.Noodl.Object.get('--ndl--global-variables');
@@ -19,6 +21,7 @@ export default function createNoodlAPI(noodlRuntime) {
global.Noodl.Navigation._noodlRuntime = noodlRuntime;
global.Noodl.Files = require('./api/files');
global.Noodl.SEO = new SeoApi();
global.Noodl.Config = createConfigAPI(global.Noodl.getMetaData);
if (!global.Noodl.Env) {
global.Noodl.Env = {};
}

View File

@@ -28,6 +28,19 @@ type GlobalNoodl = {
CloudFunctions: TSFixme;
Navigation: TSFixme;
Files: TSFixme;
/**
* App Configuration - Immutable configuration values defined in App Setup.
* Access app-wide settings like API keys, feature flags, and metadata.
*
* @example
* ```typescript
* // Access config values
* const apiKey = Noodl.Config.apiKey;
* const appName = Noodl.Config.appName;
* const isFeatureEnabled = Noodl.Config.featureFlag;
* ```
*/
Config: Readonly<Record<string, unknown>>;
};
interface Window {