feat(static-pages): rich text extensions + HTML-mode validation

Milestone 3 of the Static Pages Module sprint.

- MarketplaceHtmlEditorComponent toolbar: horizontal rule
  (insertHorizontalRule), code block (formatBlock -> PRE), embed (prompt for
  a URL, insert a sandboxed <iframe sandbox="allow-scripts allow-same-origin"
  loading="lazy">, same prompt-based UX as the existing link/image commands -
  no new dependency, consistent with the documented no-external-rich-text-
  library decision).
- toggleCode() now validates raw HTML via schema/validators/primitives'
  validateHtml (added in M1) before committing it back to the visual surface;
  on failure it stays in code mode with an inline error instead of silently
  writing malformed markup into the contenteditable surface. Error clears on
  the next edit.
- i18n: builder.promptEmbedUrl, builder.htmlEditorInvalidHtml in interface +
  en/ru/hy.

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 10:00:33 +04:00
parent cb3dff819e
commit bfee935798
7 changed files with 66 additions and 7 deletions

View File

@@ -6,13 +6,18 @@
<button type="button" (click)="toggleCode()">{{ (showCode() ? 'builder.htmlEditorPreview' : 'builder.htmlEditorCode') | translate }}</button>
</div>
<textarea
class="html-editor-code"
rows="10"
[hidden]="!showCode()"
[value]="codeValue()"
(input)="updateCode($any($event.target).value)"
></textarea>
@if (showCode()) {
<textarea
class="html-editor-code"
[class.html-editor-code--invalid]="codeError()"
rows="10"
[value]="codeValue()"
(input)="updateCode($any($event.target).value)"
></textarea>
@if (codeError()) {
<p class="html-editor-code-error">{{ codeError() }}</p>
}
}
<div
#surface

View File

@@ -24,4 +24,14 @@
border: 1px solid var(--border, #ccc);
border-radius: 4px;
padding: 0.5rem;
&--invalid {
border-color: var(--error-color, #991b1b);
}
}
.html-editor-code-error {
margin: 0;
color: var(--error-color, #991b1b);
font-size: 0.85rem;
}

View File

@@ -1,6 +1,7 @@
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewChild, inject, signal } from '@angular/core';
import { TranslateService } from '../../../../i18n/translate.service';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { validateHtml } from '../../schema/validators/primitives';
export interface HtmlEditorToolbarCommand {
id: string;
@@ -20,6 +21,9 @@ export const HTML_EDITOR_TOOLBAR: HtmlEditorToolbarCommand[] = [
{ id: 'link', label: 'Link', command: 'createLink' },
{ id: 'image', label: 'Image', command: 'insertImage' },
{ id: 'table', label: 'Table', command: 'insertHTML', value: '<table><tr><td>&nbsp;</td><td>&nbsp;</td></tr></table>' },
{ id: 'hr', label: 'HR', command: 'insertHorizontalRule' },
{ id: 'codeblock', label: 'Code', command: 'formatBlock', value: 'PRE' },
{ id: 'embed', label: 'Embed', command: 'insertEmbed' },
];
@Component({
@@ -40,6 +44,7 @@ export class MarketplaceHtmlEditorComponent implements OnChanges, AfterViewInit
readonly toolbar = HTML_EDITOR_TOOLBAR;
readonly showCode = signal(false);
readonly codeValue = signal('');
readonly codeError = signal<string | null>(null);
ngOnChanges(changes: SimpleChanges): void {
if (changes['html']) {
@@ -73,6 +78,20 @@ export class MarketplaceHtmlEditorComponent implements OnChanges, AfterViewInit
return;
}
document.execCommand('insertImage', false, url);
} else if (item.command === 'insertEmbed') {
const url = window.prompt(this.translate.t('builder.promptEmbedUrl'));
if (!url) {
return;
}
// Sandboxed iframe: no same-origin/script escalation into the editor
// page itself. The editor already emits raw, unsanitized HTML by design
// (FACTS PE-...-0004) - sanitization happens at storefront render time
// (StaticPageComponent runs everything through DomSanitizer).
document.execCommand(
'insertHTML',
false,
`<iframe src="${this.escapeAttribute(url)}" sandbox="allow-scripts allow-same-origin" loading="lazy" style="width:100%;aspect-ratio:16/9;border:0"></iframe>`,
);
} else {
document.execCommand(item.command, false, item.value);
}
@@ -86,9 +105,19 @@ export class MarketplaceHtmlEditorComponent implements OnChanges, AfterViewInit
toggleCode(): void {
if (!this.showCode()) {
this.codeValue.set(this.surface.nativeElement.innerHTML);
this.codeError.set(null);
this.showCode.set(true);
return;
}
// Validate raw HTML before committing it back to the visual surface -
// malformed markup (an unclosed/mismatched tag) stays in code mode with
// an inline error rather than silently corrupting the visual editor.
const result = validateHtml(this.codeValue());
if (!result.ok) {
this.codeError.set(result.error ?? this.translate.t('builder.htmlEditorInvalidHtml'));
return;
}
this.codeError.set(null);
this.surface.nativeElement.innerHTML = this.codeValue();
this.showCode.set(false);
this.emitChange();
@@ -96,6 +125,13 @@ export class MarketplaceHtmlEditorComponent implements OnChanges, AfterViewInit
updateCode(value: string): void {
this.codeValue.set(value);
if (this.codeError()) {
this.codeError.set(null);
}
}
private escapeAttribute(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
}
private emitChange(): void {

View File

@@ -551,6 +551,8 @@ export const en: Translations = {
confirmLeaveUnsaved: 'You have unsaved changes. Leave anyway?',
promptLinkUrl: 'URL',
promptImageUrl: 'Image URL',
promptEmbedUrl: 'Embed URL',
htmlEditorInvalidHtml: 'This HTML has an unclosed or mismatched tag. Fix it before switching back to the visual editor.',
htmlEditorCode: 'Code',
htmlEditorPreview: 'Preview',
lastSaved: 'Last saved',

View File

@@ -551,6 +551,8 @@ export const hy: Translations = {
confirmLeaveUnsaved: 'Կան չպահված փոփոխություններ։ Այնուամենայնիվ դուրս գա՞լ։',
promptLinkUrl: 'URL',
promptImageUrl: 'Նկարի URL',
promptEmbedUrl: 'Ներդրման URL',
htmlEditorInvalidHtml: 'Այս HTML-ը ունի չփակված կամ չհամընկնող թեգ։ Ուղղեք նախքան տեսողական խմբագրիչին վերադառնալը։',
htmlEditorCode: 'Կոդ',
htmlEditorPreview: 'Նախադիտում',
lastSaved: 'Վերջին պահպանումը',

View File

@@ -551,6 +551,8 @@ export const ru: Translations = {
confirmLeaveUnsaved: 'Есть несохранённые изменения. Всё равно уйти?',
promptLinkUrl: 'URL',
promptImageUrl: 'URL изображения',
promptEmbedUrl: 'URL для встраивания',
htmlEditorInvalidHtml: 'В этом HTML есть незакрытый или несовпадающий тег. Исправьте перед возвратом к визуальному редактору.',
htmlEditorCode: 'Код',
htmlEditorPreview: 'Предпросмотр',
lastSaved: 'Последнее сохранение',

View File

@@ -549,6 +549,8 @@ export interface Translations {
confirmLeaveUnsaved: string;
promptLinkUrl: string;
promptImageUrl: string;
promptEmbedUrl: string;
htmlEditorInvalidHtml: string;
htmlEditorCode: string;
htmlEditorPreview: string;
lastSaved: string;