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,98 @@
import { BootstrapConfig } from '../../../shared/models/config';
import { ProjectValidator } from './project-validator.service';
function makeBootstrap(): BootstrapConfig {
return {
branding: { logoUrl: 'logo.png' },
localization: { supportedLocales: ['en'], defaultLocale: 'en' },
tenant: { websiteBaseUrl: 'https://dexar.market' },
staticPages: {},
pages: [
{
key: 'home',
route: { path: '/' },
sections: [
{
order: 0,
layout: { strategy: 'stack' },
widgets: [{ id: 'w1', type: 'hero', version: '1', props: {} }],
},
],
},
],
navigation: { header: [], footer: [] },
theme: {
palette: {
primary: '#497671',
secondary: '#a1b4b5',
accent: '#a7ceca',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
info: '#3b82f6',
textPrimary: '#1e3c38',
textSecondary: '#667a77',
backgroundPrimary: '#ffffff',
backgroundSecondary: '#f5f5f5',
border: '#d3dad9',
},
},
layout: { type: 'default' },
} as unknown as BootstrapConfig;
}
describe('ProjectValidator', () => {
let validator: ProjectValidator;
beforeEach(() => {
validator = new ProjectValidator();
});
it('reports no issues for a valid baseline config', () => {
expect(validator.validate(makeBootstrap())).toEqual([]);
});
it('flags an invalid palette color against its field key with error severity', () => {
const bootstrap = makeBootstrap();
(bootstrap.theme.palette as unknown as Record<string, string>)['primary'] = '#zzz';
const issues = validator.validate(bootstrap);
const color = issues.find(issue => issue.code === 'invalid-colors');
expect(color).toBeDefined();
expect(color?.fieldKey).toBe('theme.palette.primary');
expect(color?.section).toBe('theme');
expect(color?.severity).toBe('error');
});
it('flags a missing logo mapped to the branding field', () => {
const bootstrap = makeBootstrap();
bootstrap.branding.logoUrl = '';
const issue = validator.validate(bootstrap).find(i => i.code === 'missing-logo');
expect(issue?.fieldKey).toBe('branding.logoUrl');
expect(issue?.severity).toBe('error');
});
it('flags a malformed widget (missing version) as invalid-widget-config', () => {
const bootstrap = makeBootstrap();
delete (bootstrap.pages[0].sections[0].widgets[0] as { version?: string }).version;
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-widget-config');
expect(issue).toBeDefined();
expect(issue?.section).toBe('widgets');
expect(issue?.severity).toBe('error');
});
it('flags duplicate page routes as a non-blocking warning', () => {
const bootstrap = makeBootstrap();
bootstrap.pages.push({ ...bootstrap.pages[0], key: 'about', route: { path: '/about' } });
bootstrap.pages.push({ ...bootstrap.pages[0], key: 'about-copy', route: { path: '/about' } });
const issue = validator.validate(bootstrap).find(i => i.code === 'duplicate-routes');
expect(issue).toBeDefined();
expect(issue?.severity).toBe('warning');
});
it('rejects an invalid website URL', () => {
const bootstrap = makeBootstrap();
bootstrap.tenant.websiteBaseUrl = 'dexar.market';
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-url');
expect(issue?.fieldKey).toBe('tenant.websiteBaseUrl');
});
});

View File

@@ -1,16 +1,45 @@
import { Injectable } from '@angular/core';
import { BootstrapConfig } from '../../../shared/models/config';
import { ProjectEditorSectionId } from '../models/project-editor.model';
import {
extractStyleBlocks,
isValidHexColor,
isValidHttpUrl,
normalizeRoute,
validateCss,
} from '../schema/validators/primitives';
export type ValidationSeverity = 'error' | 'warning';
export interface ProjectValidationIssue {
/** Stable machine code, kept for back-compat across the sprint. */
code: string;
/** i18n key for the human-facing message. */
message: string;
/** Editor section the issue belongs to (drives per-section badges). */
section?: ProjectEditorSectionId;
/** Schema field key the issue maps to (drives inline field errors). */
fieldKey?: string;
/** `error` blocks publishing; `warning` is advisory. */
severity: ValidationSeverity;
}
const HEX_COLOR = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const HTTP_URL = /^https?:\/\/\S+$/;
const KNOWN_PLATFORM_LAYOUT_TYPES = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']);
const KNOWN_SECTION_LAYOUT_STRATEGIES = new Set(['stack', 'grid', 'hero', 'carousel', 'split']);
function error(code: string, message: string, section: ProjectEditorSectionId, fieldKey?: string): ProjectValidationIssue {
return { code, message, section, fieldKey, severity: 'error' };
}
function warning(code: string, message: string, section: ProjectEditorSectionId, fieldKey?: string): ProjectValidationIssue {
return { code, message, section, fieldKey, severity: 'warning' };
}
/**
* Centralized, schema-aware validator. Composes the pure primitives under
* `schema/validators/` and tags every issue with a section + field key so the
* UI can surface it inline and gate publishing on `severity === 'error'`.
*/
@Injectable({ providedIn: 'root' })
export class ProjectValidator {
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
@@ -19,25 +48,34 @@ export class ProjectValidator {
...this.languageIssues(bootstrap),
...this.urlIssues(bootstrap),
...this.duplicateSlugIssues(bootstrap),
...this.duplicateRouteIssues(bootstrap),
...this.homepageIssues(bootstrap),
...this.widgetConfigIssues(bootstrap),
...this.navigationIssues(bootstrap),
...this.colorIssues(bootstrap),
...this.cssIssues(bootstrap),
...this.translationIssues(bootstrap),
...this.layoutIssues(bootstrap),
];
}
private brandingIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
return bootstrap.branding.logoUrl ? [] : [{ code: 'missing-logo', message: 'builder.validationMissingLogo' }];
return bootstrap.branding.logoUrl
? []
: [error('missing-logo', 'builder.validationMissingLogo', 'branding', 'branding.logoUrl')];
}
private languageIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
return bootstrap.localization.supportedLocales.length > 0 ? [] : [{ code: 'no-languages', message: 'builder.validationNoLanguages' }];
return bootstrap.localization.supportedLocales.length > 0
? []
: [error('no-languages', 'builder.validationNoLanguages', 'languages', 'localization.supportedLocales')];
}
private urlIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const url = bootstrap.tenant.websiteBaseUrl;
return !url || HTTP_URL.test(url) ? [] : [{ code: 'invalid-url', message: 'builder.validationInvalidUrl' }];
return !url || isValidHttpUrl(url)
? []
: [error('invalid-url', 'builder.validationInvalidUrl', 'general', 'tenant.websiteBaseUrl')];
}
private duplicateSlugIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
@@ -49,16 +87,67 @@ export class ProjectValidator {
page.slug && page.slug.trim() ? page.slug : (page.route ?? '').replace(/^\//, ''),
);
const hasDuplicates = slugs.some((slug, index) => slugs.indexOf(slug) !== index);
return hasDuplicates ? [{ code: 'duplicate-slugs', message: 'builder.validationDuplicateSlugs' }] : [];
return hasDuplicates
? [error('duplicate-slugs', 'builder.validationDuplicateSlugs', 'static-pages', 'staticPages')]
: [];
}
/**
* Duplicate route ownership: two `pages` claiming the same path, or a page
* route colliding with a static-page route. Advisory (warning) - a duplicate
* here is usually a mistake but does not corrupt the config.
*/
private duplicateRouteIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const pageRoutes = bootstrap.pages
.map(page => normalizeRoute(page.route?.path ?? ''))
.filter(route => route.length > 0);
const hasPageDuplicates = pageRoutes.some((route, index) => pageRoutes.indexOf(route) !== index);
const staticPages = bootstrap.staticPages;
const staticRoutes = staticPages && !Array.isArray(staticPages)
? Object.values(staticPages)
.map(page => normalizeRoute(page.slug && page.slug.trim() ? page.slug : (page.route ?? '')))
.filter(route => route.length > 0)
: [];
const pageRouteSet = new Set(pageRoutes);
const collides = staticRoutes.some(route => pageRouteSet.has(route));
return hasPageDuplicates || collides
? [warning('duplicate-routes', 'builder.validationDuplicateRoutes', 'homepage', 'pages')]
: [];
}
private homepageIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const homePage = bootstrap.pages.find(page => page.key === 'home' || page.route.path === '/');
if (!homePage || homePage.sections.length === 0) {
return [{ code: 'empty-homepage', message: 'builder.validationEmptyHomepage' }];
return [error('empty-homepage', 'builder.validationEmptyHomepage', 'homepage', 'pages')];
}
const hasMissingWidgetType = homePage.sections.some(section => section.widgets.some(widget => !widget.type?.trim()));
return hasMissingWidgetType ? [{ code: 'missing-widget', message: 'builder.validationMissingWidget' }] : [];
return hasMissingWidgetType
? [error('missing-widget', 'builder.validationMissingWidget', 'homepage', 'pages')]
: [];
}
/**
* Structural widget integrity across every page: each widget needs an id, a
* non-empty type, a version, and a props object. Complements the homepage
* `missing-widget` check with a config-shape guard the widget engine relies on.
*/
private widgetConfigIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const malformed = bootstrap.pages.some(page =>
page.sections.some(section =>
section.widgets.some(widget =>
!widget.id?.trim() ||
!widget.type?.trim() ||
!widget.version?.trim() ||
widget.props === null ||
typeof widget.props !== 'object',
),
),
);
return malformed
? [error('invalid-widget-config', 'builder.validationInvalidWidgetConfig', 'widgets', 'pages')]
: [];
}
private navigationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
@@ -66,12 +155,37 @@ export class ProjectValidator {
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;
const keys = bootstrap.navigation.header.map(keyOf);
const hasDuplicates = keys.some((key, index) => keys.indexOf(key) !== index);
return hasDuplicates ? [{ code: 'duplicate-nav-links', message: 'builder.validationDuplicateNavLinks' }] : [];
return hasDuplicates
? [error('duplicate-nav-links', 'builder.validationDuplicateNavLinks', 'navigation', 'navigation.header')]
: [];
}
private colorIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const invalid = Object.values(bootstrap.theme.palette).some(value => !HEX_COLOR.test(value));
return invalid ? [{ code: 'invalid-colors', message: 'builder.validationInvalidColors' }] : [];
return Object.entries(bootstrap.theme.palette)
.filter(([, value]) => !isValidHexColor(value))
.map(([key]) => error('invalid-colors', 'builder.validationInvalidColors', 'theme', `theme.palette.${key}`));
}
/** Validates CSS inside `<style>` blocks of static-page HTML content. */
private cssIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const staticPages = bootstrap.staticPages;
if (!staticPages || Array.isArray(staticPages)) {
return [];
}
const htmlStrings: string[] = [];
for (const page of Object.values(staticPages)) {
if (typeof page.html === 'string') {
htmlStrings.push(page.html);
} else if (page.html && typeof page.html === 'object') {
htmlStrings.push(...Object.values(page.html));
}
}
const hasInvalidCss = htmlStrings
.flatMap(html => extractStyleBlocks(html))
.some(css => !validateCss(css).ok);
return hasInvalidCss
? [warning('invalid-css', 'builder.validationInvalidCss', 'static-pages', 'staticPages')]
: [];
}
private translationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
@@ -93,7 +207,9 @@ export class ProjectValidator {
? Object.values(staticPages).some(page => page.translations && otherLocales.some(locale => !page.translations![locale]))
: false;
return headerMissing || staticPagesMissing ? [{ code: 'missing-translations', message: 'builder.validationMissingTranslations' }] : [];
return headerMissing || staticPagesMissing
? [error('missing-translations', 'builder.validationMissingTranslations', 'languages', 'localization.supportedLocales')]
: [];
}
private layoutIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
@@ -102,6 +218,8 @@ export class ProjectValidator {
page.sections.some(section => !!section.layout?.strategy && !KNOWN_SECTION_LAYOUT_STRATEGIES.has(section.layout.strategy)),
);
return invalidPlatformLayout || invalidSectionLayout ? [{ code: 'invalid-layouts', message: 'builder.validationInvalidLayouts' }] : [];
return invalidPlatformLayout || invalidSectionLayout
? [error('invalid-layouts', 'builder.validationInvalidLayouts', 'theme', 'layout.type')]
: [];
}
}