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 { ProjectEditorState } from '../models/project-editor.model';
|
||||
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 { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
|
||||
|
||||
@@ -47,6 +48,36 @@ export class ProjectEditorFacade {
|
||||
const current = this.bootstrap();
|
||||
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(() => {
|
||||
const current = this.bootstrap();
|
||||
if (!current) {
|
||||
@@ -173,7 +204,7 @@ export class ProjectEditorFacade {
|
||||
|
||||
publish(): boolean {
|
||||
const current = this.state().bootstrap;
|
||||
if (!current || this.validationIssues().length > 0) {
|
||||
if (!current || this.hasBlockingIssues()) {
|
||||
return false;
|
||||
}
|
||||
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 { 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')]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,6 +531,10 @@ export const en: Translations = {
|
||||
validationInvalidColors: 'One or more theme colors are not valid hex colors.',
|
||||
validationMissingTranslations: 'Some content is missing translations for a supported language.',
|
||||
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',
|
||||
statusPublished: 'Published',
|
||||
unsavedChanges: 'Unsaved changes',
|
||||
|
||||
@@ -531,6 +531,10 @@ export const hy: Translations = {
|
||||
validationInvalidColors: 'Թեմայի գույներից մեկը կամ մի քանիսը վավեր hex գույն չեն։',
|
||||
validationMissingTranslations: 'Որոշ բովանդակություն թարգմանված չէ սատարվող լեզուներից մեկով։',
|
||||
validationInvalidLayouts: 'Մեկ կամ մի քանի դասավորություններ հղում են անհայտ տեսակի։',
|
||||
validationInvalidJson: 'Կոնֆիգուրացիան վավեր JSON չէ։',
|
||||
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
|
||||
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
|
||||
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
|
||||
statusDraft: 'Սևագիր',
|
||||
statusPublished: 'Հրապարակված',
|
||||
unsavedChanges: 'Չպահված փոփոխություններ',
|
||||
|
||||
@@ -531,6 +531,10 @@ export const ru: Translations = {
|
||||
validationInvalidColors: 'Один или несколько цветов темы указаны некорректно.',
|
||||
validationMissingTranslations: 'Часть контента не переведена на один из поддерживаемых языков.',
|
||||
validationInvalidLayouts: 'Один или несколько макетов ссылаются на неизвестный тип раскладки.',
|
||||
validationInvalidJson: 'Конфигурация не является допустимым JSON.',
|
||||
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
|
||||
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
|
||||
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
|
||||
statusDraft: 'Черновик',
|
||||
statusPublished: 'Опубликовано',
|
||||
unsavedChanges: 'Есть несохранённые изменения',
|
||||
|
||||
@@ -529,6 +529,10 @@ export interface Translations {
|
||||
validationInvalidColors: string;
|
||||
validationMissingTranslations: string;
|
||||
validationInvalidLayouts: string;
|
||||
validationInvalidJson: string;
|
||||
validationInvalidCss: string;
|
||||
validationDuplicateRoutes: string;
|
||||
validationInvalidWidgetConfig: string;
|
||||
statusDraft: string;
|
||||
statusPublished: string;
|
||||
unsavedChanges: string;
|
||||
|
||||
Reference in New Issue
Block a user