feat(static-pages): extend data model with route/enabled/status/media/SEO

Milestone 1 of the Static Pages Module sprint.

- StaticPageConfig / ContentPage / ContentPageBootstrapInput gain: explicit
  editable route (defaults from slug, independently overridable), enabled
  (master on/off), status: 'draft'|'published' (per-page publish lifecycle,
  independent of the whole-bootstrap draft/publish cycle), customTemplate,
  heroImage/thumbnail/gallery, and seo.robots.
- ContentPageService: normalizePage/normalizePages default missing
  enabled/status to enabled+published so existing bootstrap data never gets
  silently un-published; only the editor's createPage() opts a brand-new page
  into 'draft'. Legacy array-format pages get the same treatment.
- resolvePage now returns null (storefront 404) for a disabled or draft page,
  regardless of whether the surrounding bootstrap itself is published -
  affects the storefront static-page route AND the auto-generated footer nav
  group (both go through this same resolver), which is the correct behavior.
- validatePages extended: duplicateRoutes (route can now diverge from slug),
  invalidHtml, invalidSeo (canonical/ogImage URL shape, known robots tokens).
- New schema/validators/primitives.validateHtml: stack-based tag-balance
  check (void/self-closing elements skipped, comments stripped). Caught and
  fixed a real bug during its own spec run: the initial implementation popped
  the stack back to the nearest matching ancestor on a mismatched closing
  tag, which silently swallowed a genuinely unclosed inner tag instead of
  flagging it - now a closing tag must match the top of the stack exactly.
- toBootstrapRecord serializes the new fields; visible mirrors enabled so any
  reader of the older field name stays truthful.
- Specs: content-page.service.spec.ts (new), primitives.spec.ts (validateHtml).

Gate: tsc --noEmit, npm test (57/57), arch:check, build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-17 09:43:00 +04:00
parent 274f2a4101
commit 4861990551
7 changed files with 388 additions and 25 deletions

View File

@@ -40,9 +40,11 @@ export class StaticPagesEditorComponent {
readonly locales = computed(() => this.bootstrap()?.localization.supportedLocales ?? ['en']); readonly locales = computed(() => this.bootstrap()?.localization.supportedLocales ?? ['en']);
createPage(): void { createPage(): void {
const slug = `custom-page-${this.pages().length + 1}`;
const page: ContentPage = { const page: ContentPage = {
id: `page-${Date.now()}`, id: `page-${Date.now()}`,
slug: `custom-page-${this.pages().length + 1}`, slug,
route: slug,
title: '', title: '',
order: this.pages().length + 1, order: this.pages().length + 1,
showInFooter: false, showInFooter: false,
@@ -55,6 +57,11 @@ export class StaticPagesEditorComponent {
ru: { title: '', html: '' }, ru: { title: '', html: '' },
hy: { title: '', html: '' }, hy: { title: '', html: '' },
}, },
// New pages start disabled from the storefront's perspective until an
// author explicitly publishes them - existing bootstrap pages default
// to enabled/published on normalize (see ContentPageService.normalizePage).
enabled: true,
status: 'draft',
}; };
this.persist([...this.pages(), page]); this.persist([...this.pages(), page]);

View File

@@ -8,6 +8,7 @@ export interface ContentPageSeoConfig {
ogTitle?: string; ogTitle?: string;
ogDescription?: string; ogDescription?: string;
ogImage?: string; ogImage?: string;
robots?: string;
} }
export interface ContentPageTranslation { export interface ContentPageTranslation {
@@ -16,9 +17,13 @@ export interface ContentPageTranslation {
seo?: ContentPageSeoConfig; seo?: ContentPageSeoConfig;
} }
export type ContentPageStatus = 'draft' | 'published';
export interface ContentPage { export interface ContentPage {
id: string; id: string;
slug: string; slug: string;
/** Editable independently of `slug`; defaults from it but may diverge (e.g. legacy redirects). */
route: string;
title: string; title: string;
icon?: string; icon?: string;
order: number; order: number;
@@ -34,11 +39,20 @@ export interface ContentPage {
footerGroup?: string; footerGroup?: string;
translations: Record<string, ContentPageTranslation>; translations: Record<string, ContentPageTranslation>;
seo?: ContentPageSeoConfig; seo?: ContentPageSeoConfig;
/** Master on/off switch; a disabled page never resolves on the storefront. */
enabled: boolean;
/** Per-page publish lifecycle, independent of the whole-bootstrap draft/publish cycle. */
status: ContentPageStatus;
customTemplate?: string;
heroImage?: string;
thumbnail?: string;
gallery?: string[];
} }
export interface ContentPageBootstrapInput { export interface ContentPageBootstrapInput {
id: string; id: string;
slug: string; slug: string;
route?: string;
title?: string | LocalizedTextContent; title?: string | LocalizedTextContent;
showInFooter?: boolean; showInFooter?: boolean;
showInHeader?: boolean; showInHeader?: boolean;
@@ -55,4 +69,10 @@ export interface ContentPageBootstrapInput {
translations?: Record<string, ContentPageTranslation>; translations?: Record<string, ContentPageTranslation>;
html?: string | LocalizedHtmlContent; html?: string | LocalizedHtmlContent;
seo?: ContentPageSeoConfig | { title?: string | LocalizedTextContent; description?: string | LocalizedTextContent; keywords?: string; canonical?: string; ogTitle?: string | LocalizedTextContent; ogDescription?: string | LocalizedTextContent; ogImage?: string; robots?: string; metaTags?: Array<{ name?: string; property?: string; content: string }>; }; seo?: ContentPageSeoConfig | { title?: string | LocalizedTextContent; description?: string | LocalizedTextContent; keywords?: string; canonical?: string; ogTitle?: string | LocalizedTextContent; ogDescription?: string | LocalizedTextContent; ogImage?: string; robots?: string; metaTags?: Array<{ name?: string; property?: string; content: string }>; };
enabled?: boolean;
status?: ContentPageStatus;
customTemplate?: string;
heroImage?: string;
thumbnail?: string;
gallery?: string[];
} }

View File

@@ -0,0 +1,158 @@
import { BootstrapConfig, StaticPageConfig } from '../../../shared/models/config';
import { ContentPage } from '../models/content-page.model';
import { ContentPageService } from './content-page.service';
function makePage(overrides: Partial<StaticPageConfig> = {}): StaticPageConfig {
return {
id: 'about',
slug: 'about',
title: 'About',
translations: { en: { title: 'About', html: '<p>Hello</p>' } },
...overrides,
};
}
function makeContentPage(overrides: Partial<ContentPage> = {}): ContentPage {
return {
id: 'about',
slug: 'about',
route: 'about',
title: 'About',
order: 1,
showInFooter: true,
showInHeader: false,
showInSitemap: true,
visibility: { desktop: true, tablet: true, mobile: true },
requiresAuthentication: false,
translations: { en: { title: 'About', html: '<p>Hello</p>' } },
enabled: true,
status: 'published',
...overrides,
};
}
describe('ContentPageService', () => {
let service: ContentPageService;
beforeEach(() => {
service = new ContentPageService();
});
describe('normalizePages', () => {
it('defaults a page with no enabled/status to enabled + published (preserves pre-existing bootstrap data)', () => {
const [page] = service.normalizePages({ about: makePage() });
expect(page.enabled).toBeTrue();
expect(page.status).toBe('published');
});
it('respects explicit enabled/status when present', () => {
const [page] = service.normalizePages({ about: makePage({ enabled: false, status: 'draft' }) });
expect(page.enabled).toBeFalse();
expect(page.status).toBe('draft');
});
it('defaults route from slug when route is absent', () => {
const [page] = service.normalizePages({ about: makePage({ slug: 'about-us' }) });
expect(page.route).toBe('about-us');
});
it('keeps an explicitly different route independent of slug', () => {
const [page] = service.normalizePages({ about: makePage({ slug: 'about-us', route: '/legacy/about' }) });
expect(page.route).toBe('legacy/about');
});
it('normalizes legacy array-format pages as enabled + published', () => {
const [page] = service.normalizePages([{ key: 'terms', route: '/terms', title: 'Terms' }]);
expect(page.enabled).toBeTrue();
expect(page.status).toBe('published');
});
});
describe('resolvePage', () => {
function bootstrapWith(page: StaticPageConfig): BootstrapConfig {
return { staticPages: { [page.id]: page } } as unknown as BootstrapConfig;
}
it('resolves an enabled, published page', () => {
const resolved = service.resolvePage(bootstrapWith(makePage()), 'about', 'en');
expect(resolved).not.toBeNull();
expect(resolved?.html).toBe('<p>Hello</p>');
});
it('returns null for a disabled page even though it exists', () => {
const resolved = service.resolvePage(bootstrapWith(makePage({ enabled: false })), 'about', 'en');
expect(resolved).toBeNull();
});
it('returns null for a draft page', () => {
const resolved = service.resolvePage(bootstrapWith(makePage({ status: 'draft' })), 'about', 'en');
expect(resolved).toBeNull();
});
it('returns null for a page that does not exist', () => {
const resolved = service.resolvePage(bootstrapWith(makePage()), 'missing', 'en');
expect(resolved).toBeNull();
});
});
describe('validatePages', () => {
it('reports no issues for two distinct, valid pages', () => {
const pages = [makeContentPage({ id: 'a', slug: 'a', route: 'a' }), makeContentPage({ id: 'b', slug: 'b', route: 'b' })];
const result = service.validatePages(pages);
expect(result.duplicateSlugs).toEqual([]);
expect(result.duplicateRoutes).toEqual([]);
expect(result.emptyTitles).toEqual([]);
expect(result.invalidHtml).toEqual([]);
expect(result.invalidSeo).toEqual([]);
});
it('flags duplicate routes even when slugs differ', () => {
const pages = [
makeContentPage({ id: 'a', slug: 'a-page', route: 'shared' }),
makeContentPage({ id: 'b', slug: 'b-page', route: 'shared' }),
];
expect(service.validatePages(pages).duplicateRoutes).toEqual(['shared']);
});
it('flags malformed HTML content', () => {
const pages = [makeContentPage({ translations: { en: { title: 'About', html: '<p>Hello <strong>world</p>' } } })];
expect(service.validatePages(pages).invalidHtml).toEqual(['about']);
});
it('flags an invalid canonical URL and an unknown robots token', () => {
const pages = [makeContentPage({ seo: { canonical: 'not-a-url', robots: 'maybe' } })];
expect(service.validatePages(pages).invalidSeo).toEqual(['about']);
});
it('accepts a known robots token and a valid canonical URL', () => {
const pages = [makeContentPage({ seo: { canonical: 'https://dexar.market/about', robots: 'index,follow' } })];
expect(service.validatePages(pages).invalidSeo).toEqual([]);
});
});
describe('toBootstrapRecord', () => {
it('round-trips enabled/status/route/media through serialization', () => {
const page = makeContentPage({ enabled: false, status: 'draft', heroImage: 'hero.png', thumbnail: 'thumb.png', gallery: ['a.png'] });
const record = service.toBootstrapRecord([page]);
expect(record['about'].enabled).toBeFalse();
expect(record['about'].status).toBe('draft');
expect(record['about'].visible).toBeFalse();
expect(record['about'].heroImage).toBe('hero.png');
expect(record['about'].thumbnail).toBe('thumb.png');
expect(record['about'].gallery).toEqual(['a.png']);
expect(record['about'].route).toBe('/about');
});
it('serializes an explicitly different route', () => {
const page = makeContentPage({ route: 'legacy/about' });
const record = service.toBootstrapRecord([page]);
expect(record['about'].route).toBe('/legacy/about');
});
});
describe('normalizeSlug', () => {
it('strips leading slashes and trims', () => {
expect(service.normalizeSlug('/about ')).toBe('about');
});
});
});

View File

@@ -1,6 +1,9 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { BootstrapConfig, LocalizedHtmlContent, LocalizedTextContent, ResolvedStaticPage, StaticPageConfig, StaticPagesConfig } from '../../../shared/models/config'; import { BootstrapConfig, LocalizedHtmlContent, LocalizedTextContent, ResolvedStaticPage, StaticPageConfig, StaticPagesConfig } from '../../../shared/models/config';
import { ContentPage, ContentPageBootstrapInput } from '../models/content-page.model'; import { ContentPage, ContentPageBootstrapInput } from '../models/content-page.model';
import { isValidHttpUrl, normalizeRoute, validateHtml } from '../../project-editor/schema/validators/primitives';
const KNOWN_ROBOTS_TOKENS = new Set(['index', 'noindex', 'follow', 'nofollow', 'index,follow', 'index,nofollow', 'noindex,follow', 'noindex,nofollow']);
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ContentPageService { export class ContentPageService {
@@ -10,21 +13,29 @@ export class ContentPageService {
} }
if (Array.isArray(config)) { if (Array.isArray(config)) {
return config.map((page, index) => ({ return config.map((page, index) => {
id: page.key, const slug = this.normalizeSlug(typeof page.route === 'string' ? page.route : page.route?.path ?? page.key);
slug: this.normalizeSlug(typeof page.route === 'string' ? page.route : page.route?.path ?? page.key), return {
title: typeof page.title === 'string' ? page.title : page.title?.['en'] ?? page.key, id: page.key,
icon: undefined, slug,
order: index + 1, route: slug,
showInFooter: false, title: typeof page.title === 'string' ? page.title : page.title?.['en'] ?? page.key,
showInHeader: false, icon: undefined,
showInSitemap: true, order: index + 1,
visibility: { desktop: true, tablet: true, mobile: true }, showInFooter: false,
requiresAuthentication: false, showInHeader: false,
footerGroup: undefined, showInSitemap: true,
translations: this.normalizeLegacyTranslations(page.title, page.content), visibility: { desktop: true, tablet: true, mobile: true },
seo: undefined, requiresAuthentication: false,
})); footerGroup: undefined,
translations: this.normalizeLegacyTranslations(page.title, page.content),
seo: undefined,
// Legacy array-format pages predate enabled/status; they were always live, so
// normalizing must keep them live rather than silently un-publishing them.
enabled: page.visible !== false,
status: 'published' as const,
};
});
} }
return Object.entries(config) return Object.entries(config)
@@ -35,7 +46,9 @@ export class ContentPageService {
resolvePage(bootstrap: BootstrapConfig, keyOrSlug: string, locale: string): ResolvedStaticPage | null { resolvePage(bootstrap: BootstrapConfig, keyOrSlug: string, locale: string): ResolvedStaticPage | null {
const normalizedPages = this.normalizePages(bootstrap.staticPages); const normalizedPages = this.normalizePages(bootstrap.staticPages);
const target = normalizedPages.find(page => page.id === keyOrSlug || page.slug === this.normalizeSlug(keyOrSlug)); const target = normalizedPages.find(page => page.id === keyOrSlug || page.slug === this.normalizeSlug(keyOrSlug));
if (!target) { // A disabled or draft page never resolves on the storefront, independent of
// whether the surrounding bootstrap itself has been published.
if (!target || !target.enabled || target.status !== 'published') {
return null; return null;
} }
@@ -45,39 +58,87 @@ export class ContentPageService {
key: target.id, key: target.id,
id: target.id, id: target.id,
slug: target.slug, slug: target.slug,
route: `/${target.slug}`, route: `/${this.normalizeSlug(target.route || target.slug)}`,
title: translation.title || target.title, title: translation.title || target.title,
html: translation.html || '', html: translation.html || '',
icon: target.icon, icon: target.icon,
requiresAuthentication: target.requiresAuthentication, requiresAuthentication: target.requiresAuthentication,
seo: translation.seo ?? target.seo, seo: translation.seo ?? target.seo,
customTemplate: target.customTemplate,
heroImage: target.heroImage,
thumbnail: target.thumbnail,
gallery: target.gallery,
}; };
} }
validatePages(pages: ContentPage[]): { duplicateSlugs: string[]; emptyTitles: string[] } { validatePages(pages: ContentPage[]): {
const seen = new Set<string>(); duplicateSlugs: string[];
emptyTitles: string[];
duplicateRoutes: string[];
invalidHtml: string[];
invalidSeo: string[];
} {
const seenSlugs = new Set<string>();
const duplicateSlugs = new Set<string>(); const duplicateSlugs = new Set<string>();
const emptyTitles = new Set<string>(); const emptyTitles = new Set<string>();
const seenRoutes = new Set<string>();
const duplicateRoutes = new Set<string>();
const invalidHtml = new Set<string>();
const invalidSeo = new Set<string>();
for (const page of pages) { for (const page of pages) {
const slug = this.normalizeSlug(page.slug); const slug = this.normalizeSlug(page.slug);
if (seen.has(slug)) { if (seenSlugs.has(slug)) {
duplicateSlugs.add(slug); duplicateSlugs.add(slug);
} }
seen.add(slug); seenSlugs.add(slug);
const route = normalizeRoute(page.route || page.slug);
if (route) {
if (seenRoutes.has(route)) {
duplicateRoutes.add(route);
}
seenRoutes.add(route);
}
const hasTitle = page.title.trim().length > 0 || Object.values(page.translations).some(translation => (translation.title ?? '').trim().length > 0); const hasTitle = page.title.trim().length > 0 || Object.values(page.translations).some(translation => (translation.title ?? '').trim().length > 0);
if (!hasTitle) { if (!hasTitle) {
emptyTitles.add(page.id); emptyTitles.add(page.id);
} }
const hasInvalidHtml = Object.values(page.translations).some(translation =>
(translation.html ?? '').trim().length > 0 && !validateHtml(translation.html!).ok,
);
if (hasInvalidHtml) {
invalidHtml.add(page.id);
}
if (this.hasInvalidSeo(page)) {
invalidSeo.add(page.id);
}
} }
return { return {
duplicateSlugs: [...duplicateSlugs], duplicateSlugs: [...duplicateSlugs],
emptyTitles: [...emptyTitles], emptyTitles: [...emptyTitles],
duplicateRoutes: [...duplicateRoutes],
invalidHtml: [...invalidHtml],
invalidSeo: [...invalidSeo],
}; };
} }
private hasInvalidSeo(page: ContentPage): boolean {
const seoBlocks = [page.seo, ...Object.values(page.translations).map(translation => translation.seo)].filter(
(seo): seo is NonNullable<typeof seo> => !!seo,
);
return seoBlocks.some(seo => {
const invalidCanonical = !!seo.canonical && !isValidHttpUrl(seo.canonical);
const invalidOgImage = !!seo.ogImage && !isValidHttpUrl(seo.ogImage);
const invalidRobots = !!seo.robots && !KNOWN_ROBOTS_TOKENS.has(seo.robots.toLowerCase());
return invalidCanonical || invalidOgImage || invalidRobots;
});
}
toBootstrapRecord(pages: ContentPage[]): Record<string, StaticPageConfig> { toBootstrapRecord(pages: ContentPage[]): Record<string, StaticPageConfig> {
return pages.reduce<Record<string, StaticPageConfig>>((acc, page) => { return pages.reduce<Record<string, StaticPageConfig>>((acc, page) => {
const htmlMap = Object.entries(page.translations).reduce<LocalizedHtmlContent>((result, [locale, translation]) => { const htmlMap = Object.entries(page.translations).reduce<LocalizedHtmlContent>((result, [locale, translation]) => {
@@ -94,6 +155,7 @@ export class ContentPageService {
return result; return result;
}, {}); }, {});
const route = this.normalizeSlug(page.route || page.slug);
acc[page.id] = { acc[page.id] = {
id: page.id, id: page.id,
slug: this.normalizeSlug(page.slug), slug: this.normalizeSlug(page.slug),
@@ -109,9 +171,17 @@ export class ContentPageService {
footerGroup: page.footerGroup, footerGroup: page.footerGroup,
translations: page.translations, translations: page.translations,
seo: page.seo, seo: page.seo,
visible: true, // `visible` mirrors `enabled` so any legacy/other reader of the older
route: `/${this.normalizeSlug(page.slug)}`, // field name stays truthful; `enabled` is the authoritative gate.
visible: page.enabled,
enabled: page.enabled,
status: page.status,
route: `/${route}`,
content: htmlMap, content: htmlMap,
customTemplate: page.customTemplate,
heroImage: page.heroImage,
thumbnail: page.thumbnail,
gallery: page.gallery,
}; };
return acc; return acc;
}, {}); }, {});
@@ -130,9 +200,12 @@ export class ContentPageService {
translations[locale] = { ...(translations[locale] ?? {}), html: value }; translations[locale] = { ...(translations[locale] ?? {}), html: value };
} }
const slug = this.normalizeSlug(page.slug || page.id || key);
return { return {
id: page.id || key, id: page.id || key,
slug: this.normalizeSlug(page.slug || page.id || key), slug,
// route defaults from slug but is independently stored/editable once set.
route: page.route ? this.normalizeSlug(page.route) : slug,
title: typeof page.title === 'string' ? page.title : Object.values(titleMap)[0] ?? page.id ?? key, title: typeof page.title === 'string' ? page.title : Object.values(titleMap)[0] ?? page.id ?? key,
icon: page.icon, icon: page.icon,
order: page.order ?? index + 1, order: page.order ?? index + 1,
@@ -144,6 +217,15 @@ export class ContentPageService {
footerGroup: page.footerGroup, footerGroup: page.footerGroup,
translations, translations,
seo: this.normalizeSeo(page.seo), seo: this.normalizeSeo(page.seo),
enabled: page.enabled !== false,
// Existing bootstrap data predates this field; treat it as already-published
// so normalizing a previously-working page never silently un-publishes it.
// Only the editor's "create page" action opts a brand-new page into 'draft'.
status: page.status ?? 'published',
customTemplate: page.customTemplate,
heroImage: page.heroImage,
thumbnail: page.thumbnail,
gallery: page.gallery,
}; };
} }
@@ -188,6 +270,7 @@ export class ContentPageService {
ogTitle: first(input.ogTitle), ogTitle: first(input.ogTitle),
ogDescription: first(input.ogDescription), ogDescription: first(input.ogDescription),
ogImage: input.ogImage, ogImage: input.ogImage,
robots: (input as { robots?: string }).robots,
}; };
} }
} }

View File

@@ -5,6 +5,7 @@ import {
isValidHttpUrl, isValidHttpUrl,
normalizeRoute, normalizeRoute,
validateCss, validateCss,
validateHtml,
validateJson, validateJson,
} from './primitives'; } from './primitives';
@@ -77,4 +78,33 @@ describe('validation primitives', () => {
expect(normalizeRoute('/')).toBe(''); expect(normalizeRoute('/')).toBe('');
}); });
}); });
describe('validateHtml', () => {
it('accepts well-formed markup with nested tags', () => {
expect(validateHtml('<p>Hello <strong>world</strong></p><ul><li>one</li></ul>').ok).toBeTrue();
});
it('accepts void elements without a closing tag', () => {
expect(validateHtml('<p>Line<br>break</p><hr><img src="x.png">').ok).toBeTrue();
});
it('accepts self-closing tags', () => {
expect(validateHtml('<div><span/></div>').ok).toBeTrue();
});
it('ignores comments', () => {
expect(validateHtml('<p>hi</p><!-- <div> unclosed inside a comment -->').ok).toBeTrue();
});
it('rejects an unclosed tag', () => {
const result = validateHtml('<p>Hello <strong>world</p>');
expect(result.ok).toBeFalse();
expect(result.error).toContain('strong');
expect(result.error).toContain('p');
});
it('rejects a stray closing tag', () => {
const result = validateHtml('<p>Hello</p></div>');
expect(result.ok).toBeFalse();
expect(result.error).toContain('div');
});
it('accepts empty input', () => {
expect(validateHtml('').ok).toBeTrue();
});
});
}); });

View File

@@ -79,3 +79,55 @@ export function extractStyleBlocks(html: string): string[] {
export function normalizeRoute(route: string): string { export function normalizeRoute(route: string): string {
return (route ?? '').trim().replace(/^\/+|\/+$/g, '').toLowerCase(); return (route ?? '').trim().replace(/^\/+|\/+$/g, '').toLowerCase();
} }
/** HTML void elements per the WHATWG spec: never require (or accept) a closing tag. */
const VOID_ELEMENTS = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr',
]);
/**
* Best-effort HTML well-formedness check: strips comments, then walks tags
* with a stack, ignoring void/self-closing elements, and fails on a mismatched
* or unclosed tag. Intentionally lightweight (no DOM parser dependency,
* consistent with `validateCss` above) - it catches the common authoring
* breakages (a tag left open, a stray closing tag) without validating full
* HTML5 parsing semantics.
*/
export function validateHtml(raw: string): StringCheckResult {
const source = (raw ?? '').replace(/<!--[\s\S]*?-->/g, '');
const stack: string[] = [];
const tagRe = /<\/?([a-zA-Z][a-zA-Z0-9-]*)[^>]*?(\/)?>/g;
let match: RegExpExecArray | null;
while ((match = tagRe.exec(source)) !== null) {
const [full, rawName, selfClosingSlash] = match;
const name = rawName.toLowerCase();
const isClosing = full.startsWith('</');
if (VOID_ELEMENTS.has(name) || selfClosingSlash) {
continue;
}
if (isClosing) {
// Strict top-of-stack match: a closing tag must close the innermost open
// tag. Popping back to the nearest ancestor with the same name (instead
// of requiring an exact match) would silently swallow a genuinely
// unclosed inner tag - exactly the breakage this check exists to catch.
if (stack.length === 0) {
return { ok: false, error: `Unexpected closing tag </${name}>` };
}
if (stack[stack.length - 1] !== name) {
return { ok: false, error: `Expected </${stack[stack.length - 1]}> but found </${name}>` };
}
stack.pop();
} else {
stack.push(name);
}
}
if (stack.length > 0) {
return { ok: false, error: `Unclosed tag <${stack[stack.length - 1]}>` };
}
return { ok: true };
}

View File

@@ -28,6 +28,7 @@ export interface StaticPageTranslationConfig {
ogTitle?: string; ogTitle?: string;
ogDescription?: string; ogDescription?: string;
ogImage?: string; ogImage?: string;
robots?: string;
}; };
} }
@@ -53,6 +54,14 @@ export interface StaticPageConfig {
visible?: boolean; visible?: boolean;
route?: string; route?: string;
content?: LocalizedHtmlContent; content?: LocalizedHtmlContent;
/** Master on/off switch. Kept in sync with `visible` on serialize; a disabled page never resolves on the storefront regardless of `status`. */
enabled?: boolean;
/** Per-page publish lifecycle, independent of the whole-bootstrap draft/publish cycle. A `draft` page never resolves on the storefront even when the surrounding bootstrap is published. */
status?: 'draft' | 'published';
customTemplate?: string;
heroImage?: string;
thumbnail?: string;
gallery?: string[];
} }
export interface LegacyStaticPageConfig { export interface LegacyStaticPageConfig {
@@ -84,4 +93,8 @@ export interface ResolvedStaticPage {
ogImage?: string; ogImage?: string;
robots?: string; robots?: string;
}; };
customTemplate?: string;
heroImage?: string;
thumbnail?: string;
gallery?: string[];
} }