Bilder in vier Breiten, Releasearchiv fuer Hosting ohne Composer
Uploads werden weiterhin nach WebP umgewandelt, jetzt in vier statt drei Breiten (480/960/1600/2400) und ohne Hochskalieren. Hintergrundbilder waehlen ihre Variante je Geraet, weil CSS kein srcset kennt. Dabei kam ein Fehler heraus, den erst der Browsertest zeigte: der Dokumentpruefer erlaubte drei sources je Bild, also war mit der vierten Variante jedes Dokument mit einem Bild ungueltig - ohne sichtbare Meldung. Die Grenze haengt jetzt an MediaLibrary::WIDTHS. Geteiltes Hosting hat meistens keinen Composer. scripts/build-release.php baut deshalb auf einem Build-Rechner ein Archiv mit fertigem vendor/ ohne Entwicklungspakete, samt SHA-256. Der Updater bricht ab, wenn ein Update composer.lock aendert und Composer auf dem Server nicht aufrufbar ist, statt mit einem unpassenden vendor/ weiterzulaufen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c046681302
commit
c3bff7d936
17 files changed
+414
-26
No files matched your search
@@ -267,7 +267,10 @@ final class DocumentValidator
|
||||
$this->require(is_string($props['mediaId']) && 1 === preg_match('/^[a-f0-9]{32}$/D', $props['mediaId']), 'Invalid media ID.');
|
||||
}
|
||||
if (isset($props['sources'])) {
|
||||
$this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= 3, 'Invalid image variants.');
|
||||
// As many entries as the media library produces widths, not one more: the list ends
|
||||
// up in a srcset, and the number has to move with the pipeline instead of standing
|
||||
// here as a number nobody connects to it.
|
||||
$this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= count(MediaLibrary::WIDTHS), 'Invalid image variants.');
|
||||
$widths = [];
|
||||
foreach ($props['sources'] as $source) {
|
||||
$this->require(is_array($source) && is_int($source['width'] ?? null) && $source['width'] > 0 && $source['width'] <= 8000 && !in_array($source['width'], $widths, true), 'Invalid image variant width.');
|
||||
|
||||
@@ -11,6 +11,9 @@ final class MediaLibrary
|
||||
/** Enough to fill a screen, few enough that the browser does not decode a hundred images. */
|
||||
public const PER_PAGE = 24;
|
||||
|
||||
/** The widths every upload is rendered to, in WebP. A source narrower than a step is skipped. */
|
||||
public const WIDTHS = [480, 960, 1600, 2400];
|
||||
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
@@ -109,7 +112,10 @@ final class MediaLibrary
|
||||
$variants = [];
|
||||
$width = imagesx($source);
|
||||
$height = imagesy($source);
|
||||
foreach (array_unique([min(480, $width), min(960, $width), min(1600, $width)]) as $targetWidth) {
|
||||
// Four widths, and never larger than the original: 480 for a phone, 960 for a tablet
|
||||
// or a column, 1600 for a full width section, 2400 for a full bleed image on a large
|
||||
// display. A smaller source simply produces fewer variants.
|
||||
foreach (array_unique(array_map(static fn (int $step): int => min($step, $width), self::WIDTHS)) as $targetWidth) {
|
||||
$targetHeight = max(1, (int) round($height * $targetWidth / $width));
|
||||
$target = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
try {
|
||||
|
||||
@@ -107,17 +107,17 @@ final class StyleCompiler
|
||||
continue;
|
||||
}
|
||||
$rules = '';
|
||||
$base = $this->declarations($values);
|
||||
$base = $this->declarations($values, 'self', $device);
|
||||
if ('' !== $base) {
|
||||
$rules .= sprintf('#%s{%s}', $id, $base);
|
||||
}
|
||||
// Some properties belong on the images inside the block, not on the block: object-fit
|
||||
// on a figure does nothing, and a radius on the figure leaves the image square.
|
||||
$images = $this->declarations($values, 'img');
|
||||
$images = $this->declarations($values, 'img', $device);
|
||||
if ('' !== $images) {
|
||||
$rules .= sprintf('#%s img{%s}', $id, $images);
|
||||
}
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover']) : '';
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'self', $device) : '';
|
||||
if ('' !== $hover) {
|
||||
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
|
||||
}
|
||||
@@ -138,7 +138,7 @@ final class StyleCompiler
|
||||
*
|
||||
* @param string $target 'self' for the block, 'img' for the images inside it
|
||||
*/
|
||||
private function declarations(array $values, string $target = 'self'): string
|
||||
private function declarations(array $values, string $target = 'self', string $device = 'desktop'): string
|
||||
{
|
||||
$properties = StyleSchema::properties();
|
||||
$out = [];
|
||||
@@ -173,7 +173,7 @@ final class StyleCompiler
|
||||
// The composed declarations describe the block itself; none of them has an image variant.
|
||||
if ('self' === $target) {
|
||||
foreach ([
|
||||
$this->background($values),
|
||||
$this->background($values, $device),
|
||||
$this->shadow($values),
|
||||
$this->textShadow($values),
|
||||
$this->filter($values),
|
||||
@@ -203,7 +203,7 @@ final class StyleCompiler
|
||||
}
|
||||
|
||||
/** Overlay, gradient and image share one background-image declaration, layered in that order. */
|
||||
private function background(array $values): string
|
||||
private function background(array $values, string $device = 'desktop'): string
|
||||
{
|
||||
$layers = [];
|
||||
$overlay = $values['overlayColor'] ?? null;
|
||||
@@ -223,7 +223,9 @@ final class StyleCompiler
|
||||
if (isset($values['backgroundImage']) && null !== $this->media) {
|
||||
$item = $this->media->find((string) $values['backgroundImage']);
|
||||
if (null !== $item && [] !== $item['variants']) {
|
||||
$largest = end($item['variants']);
|
||||
// A phone should not download the 2400 pixel variant of a background. CSS has no
|
||||
// srcset, but the rules are already written per device, so the device decides.
|
||||
$largest = $this->variantFor($item['variants'], $device);
|
||||
// The URL comes from the media library, never from the document.
|
||||
$layers[] = sprintf('url("%s")', $largest['src']);
|
||||
}
|
||||
@@ -314,6 +316,25 @@ final class StyleCompiler
|
||||
return [] === $parts ? '' : 'transform:'.implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* The variant a device should load for a background.
|
||||
*
|
||||
* Mobile takes the smallest that is at least 960 wide, tablet the one around 1600, desktop the
|
||||
* largest. A picture that only has small variants simply gives what it has.
|
||||
*/
|
||||
private function variantFor(array $variants, string $device): array
|
||||
{
|
||||
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
||||
$chosen = $variants[0];
|
||||
foreach ($variants as $variant) {
|
||||
if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) {
|
||||
$chosen = $variant;
|
||||
}
|
||||
}
|
||||
|
||||
return $chosen;
|
||||
}
|
||||
|
||||
private function grid(array $values): string
|
||||
{
|
||||
return isset($values['gridColumns'])
|
||||
|
||||
@@ -12,6 +12,9 @@ namespace App\Ops;
|
||||
*/
|
||||
final class CommandRunner
|
||||
{
|
||||
/** @var array<string, list<string>|null> */
|
||||
private array $resolved = [];
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
if (!function_exists('proc_open')) {
|
||||
@@ -79,9 +82,41 @@ final class CommandRunner
|
||||
return ['ok' => 0 === $exit, 'exit' => $exit, 'output' => trim($output)];
|
||||
}
|
||||
|
||||
/** Whether an executable can be found at all, so a missing one is reported, not guessed at. */
|
||||
/**
|
||||
* Whether an executable can be found at all, so a missing one is reported, not guessed at.
|
||||
*
|
||||
* On Windows several of these are batch files, and proc_open without a shell cannot start a
|
||||
* .bat - which reported "composer is not available" on a machine that has it. The variants are
|
||||
* tried in order, and the one that answers is remembered for the next call.
|
||||
*/
|
||||
public function has(string $executable, string $cwd): bool
|
||||
{
|
||||
return $this->run([$executable, '--version'], $cwd, 30)['ok'];
|
||||
return null !== $this->resolve($executable, $cwd);
|
||||
}
|
||||
|
||||
/** The spelling of a command that actually starts here, or null. */
|
||||
public function resolve(string $executable, string $cwd): ?array
|
||||
{
|
||||
if (array_key_exists($executable, $this->resolved)) {
|
||||
return $this->resolved[$executable];
|
||||
}
|
||||
$candidates = [[$executable]];
|
||||
if ('\\' === DIRECTORY_SEPARATOR) {
|
||||
// On Windows composer is a .bat, and CreateProcess cannot start a batch file on its
|
||||
// own - it needs the command interpreter. The argument list stays fixed, nothing from
|
||||
// a request ever reaches it.
|
||||
$candidates[] = [getenv('COMSPEC') ?: 'cmd.exe', '/c', $executable];
|
||||
}
|
||||
// A phar in the project is the way on hosting where nothing is installed globally.
|
||||
if (is_file($cwd.'/'.$executable.'.phar')) {
|
||||
$candidates[] = [PHP_BINARY, $cwd.'/'.$executable.'.phar'];
|
||||
}
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->run([...$candidate, '--version'], $cwd, 30)['ok']) {
|
||||
return $this->resolved[$executable] = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->resolved[$executable] = null;
|
||||
}
|
||||
}
|
||||
+24
-5
@@ -195,6 +195,9 @@ final class Updater
|
||||
return ['ok' => false, 'steps' => $steps, 'target' => null];
|
||||
}
|
||||
$from = $this->git(['rev-parse', 'HEAD'])['output'];
|
||||
// Remembered before the checkout: if the dependencies change and Composer is missing,
|
||||
// this is what tells us - vendor/ is outside Git and would silently stay old.
|
||||
$lockBefore = $this->lockHash();
|
||||
$step($steps, 'Ziel bestimmen', true, sprintf('%s (von %s, Fassung %s)', $target, substr($from, 0, 8), $this->version()));
|
||||
|
||||
if ($target === 'v'.$this->version()) {
|
||||
@@ -229,13 +232,20 @@ final class Updater
|
||||
throw new \RuntimeException('version');
|
||||
}
|
||||
|
||||
if ($this->runner->has('composer', $this->root)) {
|
||||
$install = $this->runner->run(['composer', 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
|
||||
$composer = $this->runner->resolve('composer', $this->root);
|
||||
if (null !== $composer) {
|
||||
$install = $this->runner->run([...$composer, 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
|
||||
if (!$step($steps, 'Abhängigkeiten', $install['ok'], $install['ok'] ? 'composer install ausgeführt.' : $install['output'])) {
|
||||
throw new \RuntimeException('composer');
|
||||
}
|
||||
} elseif ($lockBefore !== $this->lockHash()) {
|
||||
// Without Composer the dependencies cannot be brought up to date, and this update
|
||||
// needs different ones. Going on would leave an installation whose code and
|
||||
// vendor/ do not match - which fails somewhere else, later, for no visible reason.
|
||||
$step($steps, 'Abhängigkeiten', false, 'Dieses Update ändert die Abhängigkeiten (composer.lock), und auf diesem Server ist Composer nicht aufrufbar. Spiele stattdessen das Releasearchiv ein, das vendor/ mitbringt.');
|
||||
throw new \RuntimeException('lock');
|
||||
} else {
|
||||
$step($steps, 'Abhängigkeiten', true, 'Composer ist nicht vorhanden; vendor/ kommt aus dem Tag.');
|
||||
$step($steps, 'Abhängigkeiten', true, 'Composer ist nicht vorhanden; composer.lock ist unverändert, vendor/ bleibt gültig.');
|
||||
}
|
||||
|
||||
$migrated = true;
|
||||
@@ -312,8 +322,9 @@ final class Updater
|
||||
: 'Die Sicherung ließ sich nicht zurückschreiben. Die Datenbank liegt unter '.dirname((string) $backup).'.');
|
||||
}
|
||||
|
||||
if ($this->runner->has('composer', $this->root)) {
|
||||
$this->runner->run(['composer', 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
|
||||
$composer = $this->runner->resolve('composer', $this->root);
|
||||
if (null !== $composer) {
|
||||
$this->runner->run([...$composer, 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
|
||||
}
|
||||
$this->console(['bin/console', 'cache:clear', '--env=prod'], 300);
|
||||
$health = $this->console(['bin/console', 'cms:health', '--env=prod'], 120);
|
||||
@@ -352,6 +363,14 @@ final class Updater
|
||||
);
|
||||
}
|
||||
|
||||
/** The checksum of composer.lock, or an empty string when there is none. */
|
||||
private function lockHash(): string
|
||||
{
|
||||
$file = $this->root.'/composer.lock';
|
||||
|
||||
return is_readable($file) ? (string) hash_file('sha256', $file) : '';
|
||||
}
|
||||
|
||||
/** @return array{ok: bool, exit: int, output: string} */
|
||||
private function git(array $arguments, int $timeout = 60): array
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user