Added custom json edit to config tab

This commit is contained in:
Richard Osborne
2026-01-08 13:27:38 +01:00
parent 4a1080d547
commit 67b8ddc9c3
53 changed files with 8756 additions and 210 deletions

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 };
}