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:
@@ -8,7 +8,8 @@ import { ProjectEditorPreviewService } from '../services/project-editor-preview.
|
|||||||
import { LocaleSyncService } from '../services/locale-sync.service';
|
import { LocaleSyncService } from '../services/locale-sync.service';
|
||||||
import { ProjectEditorState } from '../models/project-editor.model';
|
import { ProjectEditorState } from '../models/project-editor.model';
|
||||||
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
|
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 { ProjectEditorDraftStorageService } from '../services/project-editor-draft-storage.service';
|
||||||
import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
|
import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
|
||||||
|
|
||||||
@@ -47,6 +48,36 @@ export class ProjectEditorFacade {
|
|||||||
const current = this.bootstrap();
|
const current = this.bootstrap();
|
||||||
return current ? this.validator.validate(current) : [];
|
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<Map<string, ProjectValidationIssue[]>>(() => {
|
||||||
|
const map = new Map<string, ProjectValidationIssue[]>();
|
||||||
|
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<Map<ProjectEditorSectionId, number>>(() => {
|
||||||
|
const map = new Map<ProjectEditorSectionId, number>();
|
||||||
|
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(() => {
|
readonly dirty = computed(() => {
|
||||||
const current = this.bootstrap();
|
const current = this.bootstrap();
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -173,7 +204,7 @@ export class ProjectEditorFacade {
|
|||||||
|
|
||||||
publish(): boolean {
|
publish(): boolean {
|
||||||
const current = this.state().bootstrap;
|
const current = this.state().bootstrap;
|
||||||
if (!current || this.validationIssues().length > 0) {
|
if (!current || this.hasBlockingIssues()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.runtime.reloadFromBootstrap(current);
|
this.runtime.reloadFromBootstrap(current);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 { Injectable } from '@angular/core';
|
||||||
import { BootstrapConfig } from '../../../shared/models/config';
|
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 {
|
export interface ProjectValidationIssue {
|
||||||
|
/** Stable machine code, kept for back-compat across the sprint. */
|
||||||
code: string;
|
code: string;
|
||||||
|
/** i18n key for the human-facing message. */
|
||||||
message: string;
|
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_PLATFORM_LAYOUT_TYPES = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']);
|
||||||
const KNOWN_SECTION_LAYOUT_STRATEGIES = new Set(['stack', 'grid', 'hero', 'carousel', 'split']);
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ProjectValidator {
|
export class ProjectValidator {
|
||||||
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||||
@@ -19,25 +48,34 @@ export class ProjectValidator {
|
|||||||
...this.languageIssues(bootstrap),
|
...this.languageIssues(bootstrap),
|
||||||
...this.urlIssues(bootstrap),
|
...this.urlIssues(bootstrap),
|
||||||
...this.duplicateSlugIssues(bootstrap),
|
...this.duplicateSlugIssues(bootstrap),
|
||||||
|
...this.duplicateRouteIssues(bootstrap),
|
||||||
...this.homepageIssues(bootstrap),
|
...this.homepageIssues(bootstrap),
|
||||||
|
...this.widgetConfigIssues(bootstrap),
|
||||||
...this.navigationIssues(bootstrap),
|
...this.navigationIssues(bootstrap),
|
||||||
...this.colorIssues(bootstrap),
|
...this.colorIssues(bootstrap),
|
||||||
|
...this.cssIssues(bootstrap),
|
||||||
...this.translationIssues(bootstrap),
|
...this.translationIssues(bootstrap),
|
||||||
...this.layoutIssues(bootstrap),
|
...this.layoutIssues(bootstrap),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private brandingIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
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[] {
|
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[] {
|
private urlIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||||
const url = bootstrap.tenant.websiteBaseUrl;
|
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[] {
|
private duplicateSlugIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||||
@@ -49,16 +87,67 @@ export class ProjectValidator {
|
|||||||
page.slug && page.slug.trim() ? page.slug : (page.route ?? '').replace(/^\//, ''),
|
page.slug && page.slug.trim() ? page.slug : (page.route ?? '').replace(/^\//, ''),
|
||||||
);
|
);
|
||||||
const hasDuplicates = slugs.some((slug, index) => slugs.indexOf(slug) !== index);
|
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[] {
|
private homepageIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||||
const homePage = bootstrap.pages.find(page => page.key === 'home' || page.route.path === '/');
|
const homePage = bootstrap.pages.find(page => page.key === 'home' || page.route.path === '/');
|
||||||
if (!homePage || homePage.sections.length === 0) {
|
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()));
|
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[] {
|
private navigationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||||
@@ -66,12 +155,37 @@ export class ProjectValidator {
|
|||||||
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;
|
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;
|
||||||
const keys = bootstrap.navigation.header.map(keyOf);
|
const keys = bootstrap.navigation.header.map(keyOf);
|
||||||
const hasDuplicates = keys.some((key, index) => keys.indexOf(key) !== index);
|
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[] {
|
private colorIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||||
const invalid = Object.values(bootstrap.theme.palette).some(value => !HEX_COLOR.test(value));
|
return Object.entries(bootstrap.theme.palette)
|
||||||
return invalid ? [{ code: 'invalid-colors', message: 'builder.validationInvalidColors' }] : [];
|
.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[] {
|
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]))
|
? Object.values(staticPages).some(page => page.translations && otherLocales.some(locale => !page.translations![locale]))
|
||||||
: false;
|
: 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[] {
|
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)),
|
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')]
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -531,6 +531,10 @@ export const en: Translations = {
|
|||||||
validationInvalidColors: 'One or more theme colors are not valid hex colors.',
|
validationInvalidColors: 'One or more theme colors are not valid hex colors.',
|
||||||
validationMissingTranslations: 'Some content is missing translations for a supported language.',
|
validationMissingTranslations: 'Some content is missing translations for a supported language.',
|
||||||
validationInvalidLayouts: 'One or more layouts reference an unknown layout type.',
|
validationInvalidLayouts: 'One or more layouts reference an unknown layout type.',
|
||||||
|
validationInvalidJson: 'The configuration is not valid JSON.',
|
||||||
|
validationInvalidCss: 'A static page contains invalid CSS.',
|
||||||
|
validationDuplicateRoutes: 'Two or more pages share the same route.',
|
||||||
|
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).',
|
||||||
statusDraft: 'Draft',
|
statusDraft: 'Draft',
|
||||||
statusPublished: 'Published',
|
statusPublished: 'Published',
|
||||||
unsavedChanges: 'Unsaved changes',
|
unsavedChanges: 'Unsaved changes',
|
||||||
|
|||||||
@@ -531,6 +531,10 @@ export const hy: Translations = {
|
|||||||
validationInvalidColors: 'Թեմայի գույներից մեկը կամ մի քանիսը վավեր hex գույն չեն։',
|
validationInvalidColors: 'Թեմայի գույներից մեկը կամ մի քանիսը վավեր hex գույն չեն։',
|
||||||
validationMissingTranslations: 'Որոշ բովանդակություն թարգմանված չէ սատարվող լեզուներից մեկով։',
|
validationMissingTranslations: 'Որոշ բովանդակություն թարգմանված չէ սատարվող լեզուներից մեկով։',
|
||||||
validationInvalidLayouts: 'Մեկ կամ մի քանի դասավորություններ հղում են անհայտ տեսակի։',
|
validationInvalidLayouts: 'Մեկ կամ մի քանի դասավորություններ հղում են անհայտ տեսակի։',
|
||||||
|
validationInvalidJson: 'Կոնֆիգուրացիան վավեր JSON չէ։',
|
||||||
|
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
|
||||||
|
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
|
||||||
|
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
|
||||||
statusDraft: 'Սևագիր',
|
statusDraft: 'Սևագիր',
|
||||||
statusPublished: 'Հրապարակված',
|
statusPublished: 'Հրապարակված',
|
||||||
unsavedChanges: 'Չպահված փոփոխություններ',
|
unsavedChanges: 'Չպահված փոփոխություններ',
|
||||||
|
|||||||
@@ -531,6 +531,10 @@ export const ru: Translations = {
|
|||||||
validationInvalidColors: 'Один или несколько цветов темы указаны некорректно.',
|
validationInvalidColors: 'Один или несколько цветов темы указаны некорректно.',
|
||||||
validationMissingTranslations: 'Часть контента не переведена на один из поддерживаемых языков.',
|
validationMissingTranslations: 'Часть контента не переведена на один из поддерживаемых языков.',
|
||||||
validationInvalidLayouts: 'Один или несколько макетов ссылаются на неизвестный тип раскладки.',
|
validationInvalidLayouts: 'Один или несколько макетов ссылаются на неизвестный тип раскладки.',
|
||||||
|
validationInvalidJson: 'Конфигурация не является допустимым JSON.',
|
||||||
|
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
|
||||||
|
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
|
||||||
|
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
|
||||||
statusDraft: 'Черновик',
|
statusDraft: 'Черновик',
|
||||||
statusPublished: 'Опубликовано',
|
statusPublished: 'Опубликовано',
|
||||||
unsavedChanges: 'Есть несохранённые изменения',
|
unsavedChanges: 'Есть несохранённые изменения',
|
||||||
|
|||||||
@@ -529,6 +529,10 @@ export interface Translations {
|
|||||||
validationInvalidColors: string;
|
validationInvalidColors: string;
|
||||||
validationMissingTranslations: string;
|
validationMissingTranslations: string;
|
||||||
validationInvalidLayouts: string;
|
validationInvalidLayouts: string;
|
||||||
|
validationInvalidJson: string;
|
||||||
|
validationInvalidCss: string;
|
||||||
|
validationDuplicateRoutes: string;
|
||||||
|
validationInvalidWidgetConfig: string;
|
||||||
statusDraft: string;
|
statusDraft: string;
|
||||||
statusPublished: string;
|
statusPublished: string;
|
||||||
unsavedChanges: string;
|
unsavedChanges: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user