Freie Gestaltung statt Tokenlisten (Dokumentschema v10)
Der bisherige Editor bot drei Farben, keine Schriftwahl und elf Auswahllisten mit 44 festen Werten. ADR 008 hatte das mit Sicherheit begründet - der Schluss war falsch: die Alternative zu beliebigem CSS-Text ist nicht eine Auswahlliste, sondern ein typisiertes Designmodell. StyleSchema erklärt jede Eigenschaft einmal mit Typ, CSS-Entsprechung und Grenzen. Validator, CSS-Erzeugung und Editorpanel lesen dieselbe Definition, also kann der Editor nichts anbieten, was der Server ablehnt. Rund fünfzig Eigenschaften in sieben Gruppen, je Block, je Gerät und für den Hover-Zustand, mit Vererbung und einzelnem Zurücksetzen. Werte sind kurze Zeichenketten mit verankertem Muster, nie CSS-Text; zusammengesetzte Deklarationen baut der Compiler selbst. Der Server erzeugt daraus pro Seite ein blockgenaues Stylesheet, das über seinen eigenen SHA-256-Hash erlaubt wird statt über unsafe-inline. Theme unter /admin/theme: freie Farbpalette als @schlüssel, Schriften aus Systemschrift oder Google Fonts, Grundtypografie mit modularer Überschriftenskala. Google Fonts auf ausdrückliche Nutzerentscheidung; die Richtlinie öffnet die Hosts nur, wenn wirklich eine eingestellt ist. Alte Tokenwerte aus v2 bis v9 bleiben gültig und im Editor unter "Klassische Werte dieser Seite" bearbeitbar. Geprüft: 154 PHP-Tests / 563 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, Lint/Format/Container. Im Browser wurde nur der Aufbau des Panels bestätigt, kein Gestaltungsvorgang von Hand ausgeführt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a40c2bb27e
commit
abf0bd2c6b
24 files changed
+1591
-21
No files matched your search
@@ -10,6 +10,7 @@ use App\Content\PageRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidMenu;
|
||||
use App\Domain\InvalidPage;
|
||||
@@ -39,6 +40,7 @@ final class CmsTest extends TestCase
|
||||
private PostRepository $posts;
|
||||
private SiteSettings $site;
|
||||
private RedirectRepository $redirects;
|
||||
private Theme $themes;
|
||||
private string $path;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -67,6 +69,7 @@ final class CmsTest extends TestCase
|
||||
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'));
|
||||
$this->site = new SiteSettings($this->db);
|
||||
$this->redirects = new RedirectRepository($this->db);
|
||||
$this->themes = new Theme($this->db);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
@@ -1048,6 +1051,164 @@ final class CmsTest extends TestCase
|
||||
self::assertSame(1, substr_count($body, 'Sitemap:'));
|
||||
}
|
||||
|
||||
private static function styledDocument(): array
|
||||
{
|
||||
$document = DemoContent::starter('Gestaltet');
|
||||
$document['schemaVersion'] = 10;
|
||||
$document['blocks'][0]['style'] = [
|
||||
'desktop' => [
|
||||
'backgroundColor' => '@mint',
|
||||
'color' => '#123456',
|
||||
'padding' => ['top' => '80px', 'bottom' => '80px'],
|
||||
'borderRadius' => ['topLeft' => '24px'],
|
||||
'fontSize' => '42px',
|
||||
'letterSpacing' => '-1.5px',
|
||||
'shadowY' => '12px', 'shadowBlur' => '40px', 'shadowColor' => '#00000022',
|
||||
'hover' => ['backgroundColor' => '#ffffff', 'scale' => 1.02],
|
||||
],
|
||||
'mobile' => ['fontSize' => '28px', 'padding' => ['top' => '32px']],
|
||||
];
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
public function testFreeStylesSurviveSavingAndBecomeScopedCss(): void
|
||||
{
|
||||
$document = self::styledDocument();
|
||||
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
||||
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
|
||||
$this->pages->publish('page-1', $revision, 1);
|
||||
|
||||
$html = $this->request('/')->getContent();
|
||||
// Values become declarations scoped to the block, palette colours become variables.
|
||||
self::assertStringContainsString('#start-hero{', $html);
|
||||
self::assertStringContainsString('background-color:var(--k-mint)', $html);
|
||||
self::assertStringContainsString('color:#123456', $html);
|
||||
self::assertStringContainsString('padding-top:80px', $html);
|
||||
self::assertStringContainsString('border-top-left-radius:24px', $html);
|
||||
self::assertStringContainsString('letter-spacing:-1.5px', $html);
|
||||
self::assertStringContainsString('box-shadow:0 12px 40px 0 #00000022', $html);
|
||||
// Hover and the narrow breakpoint get their own rules.
|
||||
self::assertStringContainsString('#start-hero:hover,#start-hero:focus-within{', $html);
|
||||
self::assertStringContainsString('transform:scale(1.02)', $html);
|
||||
self::assertStringContainsString('@media (max-width:767px){#start-hero{font-size:28px', $html);
|
||||
}
|
||||
|
||||
public function testInlineStylesheetIsAllowedByItsOwnHashAndNothingElse(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', self::styledDocument(), 0), 1);
|
||||
$response = $this->request('/');
|
||||
$policy = (string) $response->headers->get('Content-Security-Policy');
|
||||
|
||||
preg_match('#<style>(.*?)</style>#s', $response->getContent(), $match);
|
||||
self::assertNotEmpty($match[1] ?? '', 'The page carries a generated stylesheet.');
|
||||
$expected = base64_encode(hash('sha256', html_entity_decode($match[1], ENT_QUOTES), true));
|
||||
self::assertStringContainsString(sprintf("'sha256-%s'", $expected), $policy);
|
||||
// Never a blanket permission for inline styles, and scripting stays off.
|
||||
self::assertStringNotContainsString('unsafe-inline', $policy);
|
||||
self::assertStringContainsString("script-src 'none'", $policy);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{array}>
|
||||
*/
|
||||
public static function rejectedStyles(): iterable
|
||||
{
|
||||
$wrap = static function (array $style): array {
|
||||
$document = DemoContent::starter('Titel');
|
||||
$document['schemaVersion'] = 10;
|
||||
$document['blocks'][0]['style'] = $style;
|
||||
|
||||
return [$document];
|
||||
};
|
||||
yield 'unknown property' => $wrap(['desktop' => ['zIndexHack' => '9999']]);
|
||||
yield 'unknown device' => $wrap(['watch' => ['fontSize' => '12px']]);
|
||||
yield 'colour that is a word' => $wrap(['desktop' => ['color' => 'red']]);
|
||||
yield 'colour with a function' => $wrap(['desktop' => ['color' => 'rgb(1,2,3)']]);
|
||||
yield 'length without unit' => $wrap(['desktop' => ['fontSize' => '18']]);
|
||||
yield 'length with expression' => $wrap(['desktop' => ['fontSize' => 'calc(1px + 2px)']]);
|
||||
yield 'value with a declaration' => $wrap(['desktop' => ['fontSize' => '18px;position:fixed']]);
|
||||
yield 'choice outside the list' => $wrap(['desktop' => ['display' => 'contents']]);
|
||||
yield 'number out of range' => $wrap(['desktop' => ['opacity' => 4]]);
|
||||
yield 'side given as a single value' => $wrap(['desktop' => ['padding' => '10px']]);
|
||||
yield 'unknown side' => $wrap(['desktop' => ['padding' => ['middle' => '10px']]]);
|
||||
yield 'hover on a property without hover' => $wrap(['desktop' => ['hover' => ['fontSize' => '10px']]]);
|
||||
yield 'font key with quotes' => $wrap(['desktop' => ['fontFamily' => '"Inter",serif']]);
|
||||
yield 'background that is not a media id' => $wrap(['desktop' => ['backgroundImage' => '/etc/passwd']]);
|
||||
}
|
||||
|
||||
#[DataProvider('rejectedStyles')]
|
||||
public function testRejectsInvalidStylesBeforeWriting(array $document): void
|
||||
{
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
public function testFreeStylesNeedSchemaTen(): void
|
||||
{
|
||||
$document = self::styledDocument();
|
||||
$document['schemaVersion'] = 9;
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
public function testThemeDrivesEveryPageAndGoogleFontsOpenTheirOwnPolicy(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
// Without a stored theme the defaults apply and no third party is contacted.
|
||||
$plain = $this->request('/');
|
||||
self::assertStringContainsString('--k-ink:#203b34', $plain->getContent());
|
||||
self::assertStringNotContainsString('fonts.googleapis.com', $plain->getContent());
|
||||
self::assertStringNotContainsString('fonts.gstatic.com', (string) $plain->headers->get('Content-Security-Policy'));
|
||||
|
||||
$this->themes->save(
|
||||
[['key' => 'marke', 'name' => 'Marke', 'value' => '#Ff3300'], ['key' => 'ink', 'name' => 'Tinte', 'value' => '#111111']],
|
||||
[['key' => 'body', 'name' => 'Text', 'family' => 'Inter', 'source' => 'google', 'weights' => [400, 700], 'fallback' => 'sans-serif']],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'body', 'fontSize' => '20px', 'lineHeight' => 1.7, 'color' => '@ink', 'backgroundColor' => '#ffffff', 'linkColor' => '@marke', 'containerWidth' => '1100px', 'headingScale' => 1.3],
|
||||
);
|
||||
|
||||
$response = $this->request('/');
|
||||
$html = $response->getContent();
|
||||
self::assertStringContainsString('--k-marke:#ff3300', $html, 'colour values are normalised');
|
||||
self::assertStringContainsString('--kf-body:"Inter",sans-serif', $html);
|
||||
self::assertStringContainsString('font-size:var(--k-size)', $html);
|
||||
self::assertStringContainsString('--k-container:1100px', $html);
|
||||
self::assertStringContainsString('fonts.googleapis.com/css2?family=Inter:wght@400;700', $html);
|
||||
$policy = (string) $response->headers->get('Content-Security-Policy');
|
||||
self::assertStringContainsString('https://fonts.googleapis.com', $policy);
|
||||
self::assertStringContainsString("font-src 'self' https://fonts.gstatic.com", $policy);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{array, array, array}>
|
||||
*/
|
||||
public static function rejectedThemes(): iterable
|
||||
{
|
||||
$font = ['key' => 'body', 'name' => 'Text', 'family' => '', 'source' => 'system', 'weights' => [400], 'fallback' => 'system-ui'];
|
||||
$base = ['fontFamily' => 'body', 'headingFontFamily' => 'body', 'color' => '@ink', 'backgroundColor' => '#ffffff', 'linkColor' => '#000000', 'fontSize' => '18px', 'containerWidth' => '1240px', 'lineHeight' => 1.6, 'headingScale' => 1.25];
|
||||
yield 'colour that is not hex' => [[['key' => 'ink', 'name' => 'T', 'value' => 'darkgreen']], [$font], $base];
|
||||
yield 'duplicate colour key' => [[['key' => 'ink', 'name' => 'A', 'value' => '#000000'], ['key' => 'ink', 'name' => 'B', 'value' => '#111111']], [$font], $base];
|
||||
yield 'font family with a stack' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [[...$font, 'family' => 'Inter,serif']], $base];
|
||||
yield 'google font without a name' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [[...$font, 'source' => 'google']], $base];
|
||||
yield 'base colour without palette entry' => [[['key' => 'other', 'name' => 'T', 'value' => '#000000']], [$font], $base];
|
||||
yield 'base size without unit' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [$font], [...$base, 'fontSize' => '18']];
|
||||
yield 'line height out of range' => [[['key' => 'ink', 'name' => 'T', 'value' => '#000000']], [$font], [...$base, 'lineHeight' => 9]];
|
||||
yield 'no colour at all' => [[], [$font], $base];
|
||||
}
|
||||
|
||||
#[DataProvider('rejectedThemes')]
|
||||
public function testRejectsInvalidThemesWithoutWriting(array $colors, array $fonts, array $base): void
|
||||
{
|
||||
$before = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
|
||||
try {
|
||||
$this->themes->save($colors, $fonts, $base);
|
||||
self::fail('Expected the theme to be rejected.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
self::assertSame($before, $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']));
|
||||
self::assertSame(Theme::DEFAULTS['colors'], $this->themes->load()['colors']);
|
||||
}
|
||||
|
||||
private function request(string $url, string $method = 'GET'): Response
|
||||
{
|
||||
$previous = [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer } from '../public/assets/editor-model.js';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle } from '../public/assets/editor-model.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { designCss } from '../scripts/build-design.mjs';
|
||||
|
||||
@@ -234,3 +234,37 @@ test('a new tab group starts with two tabs and accepts nothing but tabs', () =>
|
||||
history.undo();
|
||||
assert.deepEqual(history.document, fixture());
|
||||
});
|
||||
|
||||
test('free styles inherit from the wider device, reset individually and leave no skeleton', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
history.change(doc => setStyle(doc, 's1', 'desktop', 'fontSize', '42px'));
|
||||
history.change(doc => setStyle(doc, 's1', 'desktop', 'padding', '80px', { side: 'top' }));
|
||||
history.change(doc => setStyle(doc, 's1', 'desktop', 'color', '#ffffff', { hover: true }));
|
||||
history.change(doc => setStyle(doc, 's1', 'mobile', 'fontSize', '28px'));
|
||||
const block = () => locate(history.document.blocks, 's1').block;
|
||||
|
||||
assert.equal(history.document.schemaVersion, 10);
|
||||
// Tablet has no own value, so it inherits the desktop one; mobile keeps its own.
|
||||
assert.deepEqual(effectiveStyle(block(), 'tablet', 'fontSize'), { value: '42px', source: 'desktop' });
|
||||
assert.deepEqual(effectiveStyle(block(), 'mobile', 'fontSize'), { value: '28px', source: 'mobile' });
|
||||
assert.deepEqual(effectiveStyle(block(), 'desktop', 'padding', 'top'), { value: '80px', source: 'desktop' });
|
||||
assert.equal(hoverStyle(block(), 'desktop', 'color'), '#ffffff');
|
||||
|
||||
// Clearing one value makes it inherit again and removes the now empty container.
|
||||
history.change(doc => setStyle(doc, 's1', 'mobile', 'fontSize', ''));
|
||||
assert.equal(block().style.mobile, undefined);
|
||||
assert.deepEqual(effectiveStyle(block(), 'mobile', 'fontSize'), { value: '42px', source: 'desktop' });
|
||||
|
||||
// Copying carries the styling over without sharing objects.
|
||||
history.change(doc => copyStyle(doc, 's1', 's2'));
|
||||
const copy = locate(history.document.blocks, 's2').block;
|
||||
assert.deepEqual(copy.style, block().style);
|
||||
assert.notEqual(copy.style, block().style);
|
||||
|
||||
history.change(doc => clearStyle(doc, 's1'));
|
||||
assert.equal(block().style, undefined);
|
||||
|
||||
// Every step is one history entry, so undoing all of them restores the original document.
|
||||
for (let i = 0; i < 7; i++) history.undo();
|
||||
assert.deepEqual(history.document, fixture());
|
||||
});
|
||||
Reference in new issue
Block a user