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:
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user