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