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 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-17 01:58:36 +04:00
parent 3bfe820443
commit 7b8382131d
9 changed files with 439 additions and 15 deletions

View File

@@ -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 = '<p>hi</p><style>.a{color:red}</style><style> .b{} </style>';
expect(extractStyleBlocks(html)).toEqual(['.a{color:red}', ' .b{} ']);
});
it('returns an empty array when there are no style tags', () => {
expect(extractStyleBlocks('<p>plain</p>')).toEqual([]);
});
});
describe('normalizeRoute', () => {
it('trims, strips wrapping slashes and lowercases', () => {
expect(normalizeRoute('/About/')).toBe('about');
expect(normalizeRoute(' Contact ')).toBe('contact');
expect(normalizeRoute('/')).toBe('');
});
});
});

View File

@@ -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 `<style>` block in an HTML string. */
export function extractStyleBlocks(html: string): string[] {
const blocks: string[] = [];
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(html ?? '')) !== null) {
blocks.push(match[1]);
}
return blocks;
}
/** Normalizes a route/slug for duplicate comparison: trim, strip leading/trailing slashes, lowercase. */
export function normalizeRoute(route: string): string {
return (route ?? '').trim().replace(/^\/+|\/+$/g, '').toLowerCase();
}