Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3386cbb767 | ||
|
|
f3798cc102 | ||
|
|
d464c82ac8 | ||
|
|
441e629e80 | ||
|
|
492bb96fff | ||
|
|
9e3862093e | ||
|
|
c90c03084b | ||
|
|
76e179b077 | ||
|
|
b60e599dda | ||
|
|
791c265f42 | ||
|
|
78083fac6a | ||
|
|
b9740e6c1b | ||
|
|
2adcaab5c2 | ||
|
|
9825bac3b3 | ||
|
|
b61f958ca1 |
No files matched your search
@@ -229,6 +229,26 @@
|
||||
],
|
||||
"port": 8107
|
||||
},
|
||||
{
|
||||
"name": "becker",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/becker.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/becker-media'; $env:SITE_ORIGIN='http://127.0.0.1:8116'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8116 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8116
|
||||
},
|
||||
{
|
||||
"name": "becker-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/becker.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/becker-media'; $env:SITE_ORIGIN='http://127.0.0.1:8117'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8117 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8117
|
||||
},
|
||||
{
|
||||
"name": "luximmo",
|
||||
"runtimeExecutable": "powershell",
|
||||
|
||||
Generated
+151
-151
@@ -748,16 +748,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/cache",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/cache.git",
|
||||
"reference": "f0024b7dd1e6d4f2f34b08c1a61c6c7d33febad7"
|
||||
"reference": "bbaa2ca4a93a94532285bdd5a0caf225d34ff578"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/cache/zipball/f0024b7dd1e6d4f2f34b08c1a61c6c7d33febad7",
|
||||
"reference": "f0024b7dd1e6d4f2f34b08c1a61c6c7d33febad7",
|
||||
"url": "https://api.github.com/repos/symfony/cache/zipball/bbaa2ca4a93a94532285bdd5a0caf225d34ff578",
|
||||
"reference": "bbaa2ca4a93a94532285bdd5a0caf225d34ff578",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -827,7 +827,7 @@
|
||||
"psr6"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/cache/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/cache/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -847,20 +847,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-28T10:54:34+00:00"
|
||||
"time": "2026-11-01T22:26:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/cache-contracts",
|
||||
"version": "v3.7.1",
|
||||
"version": "v3.8.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/cache-contracts.git",
|
||||
"reference": "9789738bc19af1106dc54d6afba9a0b467516cf2"
|
||||
"reference": "af26a91232085b7edd4e04be62f75e7c25159ece"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2",
|
||||
"reference": "9789738bc19af1106dc54d6afba9a0b467516cf2",
|
||||
"url": "https://api.github.com/repos/symfony/cache-contracts/zipball/af26a91232085b7edd4e04be62f75e7c25159ece",
|
||||
"reference": "af26a91232085b7edd4e04be62f75e7c25159ece",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -874,7 +874,7 @@
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.7-dev"
|
||||
"dev-main": "3.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -907,7 +907,7 @@
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/cache-contracts/tree/v3.7.1"
|
||||
"source": "https://github.com/symfony/cache-contracts/tree/v3.8.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -927,7 +927,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-05T06:23:12+00:00"
|
||||
"time": "2026-09-26T07:05:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/clock",
|
||||
@@ -1009,16 +1009,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/config",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/config.git",
|
||||
"reference": "d7f9d7495992429d916aea42d1a4d76a12dfa29f"
|
||||
"reference": "8ef666abf731e85e59cf478929dda6c7cb2fa9c4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/config/zipball/d7f9d7495992429d916aea42d1a4d76a12dfa29f",
|
||||
"reference": "d7f9d7495992429d916aea42d1a4d76a12dfa29f",
|
||||
"url": "https://api.github.com/repos/symfony/config/zipball/8ef666abf731e85e59cf478929dda6c7cb2fa9c4",
|
||||
"reference": "8ef666abf731e85e59cf478929dda6c7cb2fa9c4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1064,7 +1064,7 @@
|
||||
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/config/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/config/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1084,20 +1084,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-25T14:40:16+00:00"
|
||||
"time": "2026-11-04T08:27:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/console",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/console.git",
|
||||
"reference": "8012f842110b414fd1cc641adb0ed8423353b0bb"
|
||||
"reference": "81957c2440c05c962fe8291f318de6e85f8db330"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/8012f842110b414fd1cc641adb0ed8423353b0bb",
|
||||
"reference": "8012f842110b414fd1cc641adb0ed8423353b0bb",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/81957c2440c05c962fe8291f318de6e85f8db330",
|
||||
"reference": "81957c2440c05c962fe8291f318de6e85f8db330",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1162,7 +1162,7 @@
|
||||
"terminal"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/console/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/console/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1182,20 +1182,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-28T11:08:27+00:00"
|
||||
"time": "2026-11-03T21:47:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/dependency-injection",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/dependency-injection.git",
|
||||
"reference": "f98a1b0625d5608a723481fa104b5473e6b18ebd"
|
||||
"reference": "4df0d83a8253d95b222c2f136b9dca3a1a9451a7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f98a1b0625d5608a723481fa104b5473e6b18ebd",
|
||||
"reference": "f98a1b0625d5608a723481fa104b5473e6b18ebd",
|
||||
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/4df0d83a8253d95b222c2f136b9dca3a1a9451a7",
|
||||
"reference": "4df0d83a8253d95b222c2f136b9dca3a1a9451a7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1246,7 +1246,7 @@
|
||||
"description": "Allows you to standardize and centralize the way objects are constructed in your application",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1266,20 +1266,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-25T14:40:16+00:00"
|
||||
"time": "2026-11-01T17:38:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/deprecation-contracts",
|
||||
"version": "v3.7.1",
|
||||
"version": "v3.8.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/deprecation-contracts.git",
|
||||
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
|
||||
"reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
|
||||
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
|
||||
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/feb1edf4e546af3b85c5caebe8359a20db230ca3",
|
||||
"reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1292,7 +1292,7 @@
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.7-dev"
|
||||
"dev-main": "3.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -1317,7 +1317,7 @@
|
||||
"description": "A generic function and convention to trigger deprecation notices",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
|
||||
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.8.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1337,7 +1337,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-05T06:23:12+00:00"
|
||||
"time": "2026-09-16T14:15:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/dotenv",
|
||||
@@ -1586,16 +1586,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/event-dispatcher-contracts",
|
||||
"version": "v3.7.1",
|
||||
"version": "v3.8.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/event-dispatcher-contracts.git",
|
||||
"reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e"
|
||||
"reference": "658e29751424da3ef4758c60f769e59d1b2981eb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e",
|
||||
"reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e",
|
||||
"url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/658e29751424da3ef4758c60f769e59d1b2981eb",
|
||||
"reference": "658e29751424da3ef4758c60f769e59d1b2981eb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1609,7 +1609,7 @@
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.7-dev"
|
||||
"dev-main": "3.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -1642,7 +1642,7 @@
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1"
|
||||
"source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.8.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1662,20 +1662,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-05T06:23:12+00:00"
|
||||
"time": "2026-09-16T14:15:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/filesystem",
|
||||
"version": "v7.4.21",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/filesystem.git",
|
||||
"reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8"
|
||||
"reference": "53d609da66578dad5a39fe290e8f924ec8f05e93"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/fd0e3462be6d32a0d130e8423e4940a7688162d8",
|
||||
"reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8",
|
||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/53d609da66578dad5a39fe290e8f924ec8f05e93",
|
||||
"reference": "53d609da66578dad5a39fe290e8f924ec8f05e93",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1712,7 +1712,7 @@
|
||||
"description": "Provides basic utilities for the filesystem",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/filesystem/tree/v7.4.21"
|
||||
"source": "https://github.com/symfony/filesystem/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1732,20 +1732,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-06T20:05:19+00:00"
|
||||
"time": "2026-11-04T08:27:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/finder.git",
|
||||
"reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2"
|
||||
"reference": "c723f990aefe8c1053b18374faf4231eba84f518"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/finder/zipball/e13420a8e0a76bdde1e7be09f923e62ad13739f2",
|
||||
"reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2",
|
||||
"url": "https://api.github.com/repos/symfony/finder/zipball/c723f990aefe8c1053b18374faf4231eba84f518",
|
||||
"reference": "c723f990aefe8c1053b18374faf4231eba84f518",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1780,7 +1780,7 @@
|
||||
"description": "Finds files and directories via an intuitive fluent interface",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/finder/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/finder/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1800,20 +1800,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-19T16:56:54+00:00"
|
||||
"time": "2026-11-02T10:56:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/framework-bundle",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/framework-bundle.git",
|
||||
"reference": "17d23a62561b493b85486c9ae8c2752f36303b2c"
|
||||
"reference": "6a749f2c37cb9def53793633ec72958aaad4b359"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/17d23a62561b493b85486c9ae8c2752f36303b2c",
|
||||
"reference": "17d23a62561b493b85486c9ae8c2752f36303b2c",
|
||||
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/6a749f2c37cb9def53793633ec72958aaad4b359",
|
||||
"reference": "6a749f2c37cb9def53793633ec72958aaad4b359",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1828,7 +1828,7 @@
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/filesystem": "^7.1|^8.0",
|
||||
"symfony/finder": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-foundation": "^7.4|^8.0",
|
||||
"symfony/http-foundation": "^7.4.23|^8.1.11",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/polyfill-mbstring": "~1.0",
|
||||
"symfony/polyfill-php85": "^1.32",
|
||||
@@ -1937,7 +1937,7 @@
|
||||
"description": "Provides a tight integration between Symfony components and the Symfony full-stack framework",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1957,20 +1957,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-26T21:52:50+00:00"
|
||||
"time": "2026-11-04T07:46:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-foundation.git",
|
||||
"reference": "63eda1d0c4de229d6b3e825709e49dd5c149699a"
|
||||
"reference": "7fb73cffa06e2bcc2cb523fcccb1e8174106b206"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/63eda1d0c4de229d6b3e825709e49dd5c149699a",
|
||||
"reference": "63eda1d0c4de229d6b3e825709e49dd5c149699a",
|
||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/7fb73cffa06e2bcc2cb523fcccb1e8174106b206",
|
||||
"reference": "7fb73cffa06e2bcc2cb523fcccb1e8174106b206",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2019,7 +2019,7 @@
|
||||
"description": "Defines an object-oriented layer for the HTTP specification",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-foundation/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/http-foundation/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2039,20 +2039,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-27T13:26:03+00:00"
|
||||
"time": "2026-11-04T08:27:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-kernel",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-kernel.git",
|
||||
"reference": "87e727b33983238d3f2ea50f9f62265499ca1a6d"
|
||||
"reference": "94c57f4c5c45a311d244a0aeadb72b14ee41d25d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/87e727b33983238d3f2ea50f9f62265499ca1a6d",
|
||||
"reference": "87e727b33983238d3f2ea50f9f62265499ca1a6d",
|
||||
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/94c57f4c5c45a311d244a0aeadb72b14ee41d25d",
|
||||
"reference": "94c57f4c5c45a311d244a0aeadb72b14ee41d25d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2138,7 +2138,7 @@
|
||||
"description": "Provides a structured process for converting a Request into a Response",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-kernel/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/http-kernel/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2158,7 +2158,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-28T18:18:28+00:00"
|
||||
"time": "2026-11-04T11:26:18+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mailer",
|
||||
@@ -3064,16 +3064,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/property-access",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/property-access.git",
|
||||
"reference": "b45cfcfe0abdbba37350645dae148be29b94f25a"
|
||||
"reference": "4dce45112d7a31d530d2e0e69143fc88eaa1521f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/property-access/zipball/b45cfcfe0abdbba37350645dae148be29b94f25a",
|
||||
"reference": "b45cfcfe0abdbba37350645dae148be29b94f25a",
|
||||
"url": "https://api.github.com/repos/symfony/property-access/zipball/4dce45112d7a31d530d2e0e69143fc88eaa1521f",
|
||||
"reference": "4dce45112d7a31d530d2e0e69143fc88eaa1521f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3121,7 +3121,7 @@
|
||||
"reflection"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/property-access/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/property-access/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3141,20 +3141,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-26T21:52:50+00:00"
|
||||
"time": "2026-11-01T19:17:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/property-info",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/property-info.git",
|
||||
"reference": "25c1b823ee4e0995e57e49bc0160c43ea199a7b5"
|
||||
"reference": "1eeaa666bcd1864b883b98cad9e5188064ddac26"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/property-info/zipball/25c1b823ee4e0995e57e49bc0160c43ea199a7b5",
|
||||
"reference": "25c1b823ee4e0995e57e49bc0160c43ea199a7b5",
|
||||
"url": "https://api.github.com/repos/symfony/property-info/zipball/1eeaa666bcd1864b883b98cad9e5188064ddac26",
|
||||
"reference": "1eeaa666bcd1864b883b98cad9e5188064ddac26",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3211,7 +3211,7 @@
|
||||
"validator"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/property-info/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/property-info/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3231,20 +3231,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-25T14:40:16+00:00"
|
||||
"time": "2026-11-02T10:56:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/rate-limiter",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/rate-limiter.git",
|
||||
"reference": "97a013c93ce39c70ace5316499c0d8aaec43a7f6"
|
||||
"reference": "a65b8280000598b80b4456004280a56fdad011b3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/rate-limiter/zipball/97a013c93ce39c70ace5316499c0d8aaec43a7f6",
|
||||
"reference": "97a013c93ce39c70ace5316499c0d8aaec43a7f6",
|
||||
"url": "https://api.github.com/repos/symfony/rate-limiter/zipball/a65b8280000598b80b4456004280a56fdad011b3",
|
||||
"reference": "a65b8280000598b80b4456004280a56fdad011b3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3285,7 +3285,7 @@
|
||||
"rate-limiter"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/rate-limiter/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/rate-limiter/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3305,20 +3305,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-20T19:25:08+00:00"
|
||||
"time": "2026-11-01T19:17:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/routing",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/routing.git",
|
||||
"reference": "f79ed6fbf60d9e32d358054f1915295f3b8a6f7a"
|
||||
"reference": "0000ade604b7bc798cb1e75edf6f17df48185f73"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/routing/zipball/f79ed6fbf60d9e32d358054f1915295f3b8a6f7a",
|
||||
"reference": "f79ed6fbf60d9e32d358054f1915295f3b8a6f7a",
|
||||
"url": "https://api.github.com/repos/symfony/routing/zipball/0000ade604b7bc798cb1e75edf6f17df48185f73",
|
||||
"reference": "0000ade604b7bc798cb1e75edf6f17df48185f73",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3370,7 +3370,7 @@
|
||||
"url"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/routing/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/routing/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3390,20 +3390,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-26T21:52:50+00:00"
|
||||
"time": "2026-11-04T08:27:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/security-bundle",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/security-bundle.git",
|
||||
"reference": "0323d198b93b3ec4fd0e31a5e84516c2b28261d7"
|
||||
"reference": "1cb338633172a5ce2fb92b31f5d39315f4aa4c00"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/security-bundle/zipball/0323d198b93b3ec4fd0e31a5e84516c2b28261d7",
|
||||
"reference": "0323d198b93b3ec4fd0e31a5e84516c2b28261d7",
|
||||
"url": "https://api.github.com/repos/symfony/security-bundle/zipball/1cb338633172a5ce2fb92b31f5d39315f4aa4c00",
|
||||
"reference": "1cb338633172a5ce2fb92b31f5d39315f4aa4c00",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3415,7 +3415,7 @@
|
||||
"symfony/dependency-injection": "^6.4.11|^7.1.4|^8.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-foundation": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-foundation": "^6.4.50|^7.4.23|^8.1.11",
|
||||
"symfony/http-kernel": "^6.4.13|^7.1.6|^8.0",
|
||||
"symfony/password-hasher": "^6.4|^7.0|^8.0",
|
||||
"symfony/security-core": "^7.4.22|^8.1.10",
|
||||
@@ -3483,7 +3483,7 @@
|
||||
"description": "Provides a tight integration of the Security component into the Symfony full-stack framework",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/security-bundle/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/security-bundle/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3503,20 +3503,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-27T13:52:57+00:00"
|
||||
"time": "2026-11-04T08:27:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/security-core",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/security-core.git",
|
||||
"reference": "4efce2a1e6c3d686bf0ec9a098e425d53dc3e674"
|
||||
"reference": "c93b2194c0255496c2c0ac999821fa7f5296c7cc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/security-core/zipball/4efce2a1e6c3d686bf0ec9a098e425d53dc3e674",
|
||||
"reference": "4efce2a1e6c3d686bf0ec9a098e425d53dc3e674",
|
||||
"url": "https://api.github.com/repos/symfony/security-core/zipball/c93b2194c0255496c2c0ac999821fa7f5296c7cc",
|
||||
"reference": "c93b2194c0255496c2c0ac999821fa7f5296c7cc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3575,7 +3575,7 @@
|
||||
"description": "Symfony Security Component - Core Library",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/security-core/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/security-core/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3595,7 +3595,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-25T14:40:16+00:00"
|
||||
"time": "2026-11-01T19:17:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/security-csrf",
|
||||
@@ -3673,16 +3673,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/security-http",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/security-http.git",
|
||||
"reference": "0020c7563c188bade070cf4bc83be3ee75ceab29"
|
||||
"reference": "ea0623a02764025268bd928810f012767ea1965a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/security-http/zipball/0020c7563c188bade070cf4bc83be3ee75ceab29",
|
||||
"reference": "0020c7563c188bade070cf4bc83be3ee75ceab29",
|
||||
"url": "https://api.github.com/repos/symfony/security-http/zipball/ea0623a02764025268bd928810f012767ea1965a",
|
||||
"reference": "ea0623a02764025268bd928810f012767ea1965a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3742,7 +3742,7 @@
|
||||
"description": "Symfony Security Component - HTTP Integration",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/security-http/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/security-http/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3762,20 +3762,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-28T10:54:34+00:00"
|
||||
"time": "2026-11-04T08:27:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/service-contracts",
|
||||
"version": "v3.7.3",
|
||||
"version": "v3.8.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/service-contracts.git",
|
||||
"reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257"
|
||||
"reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
|
||||
"reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
|
||||
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/052852a96fd5e5ebdb617dba22031e41a3300fb5",
|
||||
"reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3793,7 +3793,7 @@
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.7-dev"
|
||||
"dev-main": "3.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -3829,7 +3829,7 @@
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/service-contracts/tree/v3.7.3"
|
||||
"source": "https://github.com/symfony/service-contracts/tree/v3.8.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3849,7 +3849,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-27T15:39:01+00:00"
|
||||
"time": "2026-10-20T17:38:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/stopwatch",
|
||||
@@ -4010,16 +4010,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/translation-contracts",
|
||||
"version": "v3.7.1",
|
||||
"version": "v3.8.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/translation-contracts.git",
|
||||
"reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621"
|
||||
"reference": "0a3ae917402632fcba2aeddae9c98332f3d7dd05"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621",
|
||||
"reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621",
|
||||
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0a3ae917402632fcba2aeddae9c98332f3d7dd05",
|
||||
"reference": "0a3ae917402632fcba2aeddae9c98332f3d7dd05",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4032,7 +4032,7 @@
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.7-dev"
|
||||
"dev-main": "3.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -4068,7 +4068,7 @@
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/translation-contracts/tree/v3.7.1"
|
||||
"source": "https://github.com/symfony/translation-contracts/tree/v3.8.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -4088,7 +4088,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-05T06:23:12+00:00"
|
||||
"time": "2026-10-03T08:17:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/twig-bridge",
|
||||
@@ -4297,16 +4297,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/type-info",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/type-info.git",
|
||||
"reference": "f69ab198af73e1aac485c0ff5aa066d20c14adf0"
|
||||
"reference": "eca5fdb294539268ad3245b2214f32f24b337e09"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/type-info/zipball/f69ab198af73e1aac485c0ff5aa066d20c14adf0",
|
||||
"reference": "f69ab198af73e1aac485c0ff5aa066d20c14adf0",
|
||||
"url": "https://api.github.com/repos/symfony/type-info/zipball/eca5fdb294539268ad3245b2214f32f24b337e09",
|
||||
"reference": "eca5fdb294539268ad3245b2214f32f24b337e09",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4356,7 +4356,7 @@
|
||||
"type"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/type-info/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/type-info/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -4376,7 +4376,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-24T08:22:23+00:00"
|
||||
"time": "2026-11-02T09:31:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/var-dumper",
|
||||
@@ -4548,16 +4548,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/yaml",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/yaml.git",
|
||||
"reference": "1ed6afa13d48c579c51cbf32e38f50686313497b"
|
||||
"reference": "1e601207ffe627a2e621faa9d661f1d32dcf1cec"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/1ed6afa13d48c579c51cbf32e38f50686313497b",
|
||||
"reference": "1ed6afa13d48c579c51cbf32e38f50686313497b",
|
||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/1e601207ffe627a2e621faa9d661f1d32dcf1cec",
|
||||
"reference": "1e601207ffe627a2e621faa9d661f1d32dcf1cec",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4600,7 +4600,7 @@
|
||||
"description": "Loads and dumps YAML files",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/yaml/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/yaml/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -4620,7 +4620,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-18T14:39:27+00:00"
|
||||
"time": "2026-11-04T08:27:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "twig/twig",
|
||||
@@ -7578,16 +7578,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/browser-kit",
|
||||
"version": "v7.4.22",
|
||||
"version": "v7.4.23",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/browser-kit.git",
|
||||
"reference": "018a7c9b63d9ed402762a2d9f59238207928a2ed"
|
||||
"reference": "a0d19ca90c1fcce29d3337decb761b5adb808965"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/018a7c9b63d9ed402762a2d9f59238207928a2ed",
|
||||
"reference": "018a7c9b63d9ed402762a2d9f59238207928a2ed",
|
||||
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/a0d19ca90c1fcce29d3337decb761b5adb808965",
|
||||
"reference": "a0d19ca90c1fcce29d3337decb761b5adb808965",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7628,7 +7628,7 @@
|
||||
"description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/browser-kit/tree/v7.4.22"
|
||||
"source": "https://github.com/symfony/browser-kit/tree/v7.4.23"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7648,7 +7648,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-20T19:25:08+00:00"
|
||||
"time": "2026-11-01T07:00:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/css-selector",
|
||||
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
tags: ['kant.task']
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude: '../src/{Kernel.php,Domain/}'
|
||||
exclude: '../src/{Kernel.php,Domain/,Plugin/PluginApi.php,Plugin/PluginTask.php}'
|
||||
Doctrine\DBAL\Connection:
|
||||
factory: ['App\Infrastructure\ConnectionFactory', 'create']
|
||||
arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
|
||||
@@ -156,6 +156,7 @@ services:
|
||||
arguments:
|
||||
$tasks: !tagged_iterator kant.task
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$plugins: '@App\Plugin\PluginRuntime'
|
||||
App\Ops\Zentrale:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 127 – Plugins mit eigenem PHP-Code, wie bei WordPress (09.11.2026)
|
||||
|
||||
**Kontext:** Umut will einen ImmoScout-Abgleich für DEEG, ausdrücklich als Plugin und nicht im Kern: „Ansonsten musst du Kern so erweitern, dass Plugins das können“, „wie WordPress“. Das hebt die Grundentscheidung aus ADR 103 auf, dass ein Plugin nur Manifest plus Twig ist; dort war sie mit dem fehlenden Bedarf und der Stabilität begründet. Eigene Daten und Arbeit im Takt gehen ohne Servercode nicht.
|
||||
|
||||
**Entscheidung:** `"main"` im Manifest nennt eine PHP-Datei, die eine Funktion zurückgibt. `PluginRuntime` lädt sie für aktive Plugins, `PluginApi` nimmt Hooks, Aufgaben (im vorhandenen `Scheduler`), Seiten (durch `renderPage`, also mit Kopf, Fuß und Design), Twig-Daten und Verwaltungsbildschirme entgegen. Eigene Tabellen legt das Plugin in `onInstall` an, Präfix `plugin_<name>_`; dadurch braucht der Kern keine Plugin-Migrationen. Reine Twig-Plugins bleiben unverändert.
|
||||
|
||||
**Folgen:** Plugin-Code läuft mit vollen Rechten, das steht in der Verwaltung und wird nicht als Sandkasten bezeichnet. Anders als bei WordPress gibt es bewusst keinen ZIP-Upload in der Verwaltung: Wer PHP auf den Server legt, tut das per FTP, ein gestohlenes Adminkonto wird so nicht zur Codeausführung. Ausnahmen grenzt die Runtime auf das Plugin ein, einen harten PHP-Abbruch nicht. Der Kern löst vorerst nur `sitemap.entries` aus; weitere Hooks kommen mit dem ersten Bedarf.
|
||||
|
||||
## ADR 126 – Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026)
|
||||
|
||||
**Kontext:** Bei Luximmo fiel auf, dass LocalBusiness immer der allgemeine Typ war und weder Logo noch Koordinaten kannte.
|
||||
|
||||
+46
-3
@@ -5,9 +5,9 @@ Website. Farben, Schriften und Buttons fasst es nie an – das Design gehört de
|
||||
Stylesheets und Skripten. Eingeschaltet und eingestellt wird es in der Verwaltung unter **Plugins**
|
||||
(Administrator). Hintergrund und Grenzen: ADR 102 und ADR 103 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
**Ein Plugin bringt keinen PHP-Code mit.** Alles ist Manifest plus Twig. Deshalb gibt es keine
|
||||
Hooks in Speichern oder Veröffentlichen und keine eigenen Tabellen – und deshalb kann ein Update
|
||||
des Kerns ein Plugin nicht brechen, solange die Übergaben unten stehen.
|
||||
Ein Plugin kann reines Manifest plus Twig sein. Braucht es mehr – eigene Tabellen, Aufgaben im
|
||||
Takt, Seiten aus eigenen Daten, Verwaltungsbildschirme –, bringt es wie bei WordPress eine
|
||||
`plugin.php` mit (siehe „Eigener PHP-Code“, ADR 127).
|
||||
|
||||
## Der Ordner
|
||||
|
||||
@@ -121,6 +121,49 @@ Stylesheet und die Design-Tokens des Kerns; Palette, Schriften und Buttons der W
|
||||
nur der Betreiber unter **Design**. So färbt kein Schalter eine Website um, und nichts, was dort
|
||||
eingestellt wurde, geht durch ein Plugin verloren (ADR 105).
|
||||
|
||||
## Eigener PHP-Code (`plugin.php`)
|
||||
|
||||
`"main": "plugin.php"` im Manifest lädt die Datei, solange das Plugin eingeschaltet ist. Sie gibt
|
||||
eine Funktion zurück und meldet sich dort beim Kern an:
|
||||
|
||||
```php
|
||||
<?php
|
||||
return static function (App\Plugin\PluginApi $api): void {
|
||||
$tabelle = $api->table('objekt'); // plugin_<name>_objekt
|
||||
$api->onInstall(fn () => $api->db()->executeStatement("CREATE TABLE IF NOT EXISTS $tabelle (id VARCHAR(20) PRIMARY KEY)"));
|
||||
$api->addTask('abgleich', 'Abgleich', 'Was ausbleibt, wenn sie nicht läuft.', 3600, fn (): string => 'Bericht');
|
||||
$api->addPage('/objekte/{slug}', fn (array $p): ?array => ['title' => 'Titel', 'description' => 'Text', 'blocks' => []]);
|
||||
$api->addData('liste', fn (array $argumente): array => []);
|
||||
$api->addFilter('sitemap.entries', fn (array $e, string $origin): array => $e);
|
||||
$api->addAdminPage('uebersicht', 'Übersicht', 'admin/uebersicht.html.twig', fn (): array => []);
|
||||
$api->addAdminAction('jetzt', 'Jetzt ausführen', 'abgleich'); // Aufgabenschlüssel oder Callback
|
||||
};
|
||||
```
|
||||
|
||||
| Methode | Zweck |
|
||||
|---|---|
|
||||
| `onInstall`, `onDeactivate` | Beim Einschalten und bei neuer Fassung; beim Ausschalten. Muss wiederholbar sein. Tabellen bleiben beim Ausschalten stehen. |
|
||||
| `addAction`, `addFilter` | Hooks wie bei WordPress, kleinere Priorität zuerst. |
|
||||
| `addTask` | Aufgabe im Plan des Kerns (Bildschirm „Aufgaben“); Fehler werfen, Rückgabe ist der Bericht. |
|
||||
| `addPage` | Öffentliche Seite im Rahmen der Website. Gibt `title`, `description`, `blocks` und optional `seoTitle`, `robots`, `status`, `image` zurück oder `null` für 404. Eine veröffentlichte Seite geht vor. |
|
||||
| `addData` | Werte für Vorlagen: `kant_plugin_data('name', 'schluessel', {…})`. |
|
||||
| `addAdminPage`, `addAdminAction` | Bildschirm und Knopf der Verwaltung, nur für Administratoren, Knöpfe mit CSRF-Schutz. |
|
||||
| `settings`, `db`, `table`, `media`, `fetch`, `storeImage`, `directory` | Einstellungen, Datenbank, Mediathek, HTTPS-Abruf, Bild ablegen. |
|
||||
|
||||
Der Kern ruft bisher diese Filter auf: `sitemap.entries` (`$entries`, `$origin`) und `form.context`
|
||||
(`$extra`, `$form`, `$path`: zusätzliche Angaben `Name => Text` für eine eingehende Anfrage).
|
||||
Weitere Hooks kommen in den
|
||||
Kern, wenn ein Plugin sie braucht.
|
||||
|
||||
Die SQL-Anweisungen müssen auf SQLite und MySQL laufen. Klassen und Funktionen gehören in einen
|
||||
eigenen Namensraum. Plugin-Tabellen gehören nicht zum Inhaltspaket; bei SQLite sind sie in jeder
|
||||
Sicherung, weil die ganze Datei gesichert wird.
|
||||
|
||||
**Rechte:** Der Code läuft mit den vollen Rechten der Anwendung. Das ist kein Sandkasten. Eine
|
||||
Ausnahme beim Laden, in einem Hook oder auf einer Seite nimmt nur das Plugin aus dem Spiel und
|
||||
steht auf dem Bildschirm „Plugins“. Einen echten PHP-Abbruch fängt das nicht ab: Dann den Ordner
|
||||
unter `plugins/` umbenennen.
|
||||
|
||||
## Die Vorlagen
|
||||
|
||||
### Bausteine
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
# Projektstatus
|
||||
|
||||
## Fassung 1.40.1: Symfony-Sicherheitsupdate und acht Korrekturen aus der Analyse (10.11.2026)
|
||||
|
||||
**Anlass:** Umut nach der Komplettanalyse: „Ja, 1.40.1 bauen“. Alle Installationen laufen auf SQLite (Umut), der MySQL-Punkt ist also Vorsorge.
|
||||
|
||||
**Erledigt (keine Migration, kein Schemawechsel):**
|
||||
- Symfony 7.4.22 → 7.4.23 (`composer update "symfony/*" --with-all-dependencies`, 24 Pakete); `composer audit` danach ohne Befund. `composer.json` unverändert.
|
||||
- F1: Ein Formular lässt sich von `/beitraege`, `/kategorie/…`, `/schlagwort/…` absenden (auch unter `/en/…`). Vorher 404, die Anfrage war weg. `PageController::ARCHIVE_PATH`, `FormController::submit`.
|
||||
- F2: Wahrheitswerte gehen als Zahl an die Datenbank (`form_submission.delivered`, `scheduled_task.enabled`, `plugin.active`).
|
||||
- F3: Schrittformular mit Pflicht-Mehrfachauswahl meldet „Bitte wähle mindestens eine Option.“ statt mit einem TypeError stehen zu bleiben (`form-steps.js`).
|
||||
- F5: `ArchiveUpdate::finish()` läuft unter `Updater::lock()`. Ein zweiter Abschluss zur selben Zeit meldet „läuft bereits“ und schaltet nichts zurück.
|
||||
- E1: Im Editor wählen die Einträge im Akkordeon-Panel und „Bearbeiten“ in der Galerie wieder aus (lokale Funktion heißt jetzt `setProp`).
|
||||
- S2: Kategorie- und Schlagwortnamen werden in der Archivüberschrift maskiert.
|
||||
- S3: Eine veröffentlichte Seite holt nur aus dem Papierkorb zurück, wer veröffentlichen darf; die Meldung sagt, ob die Seite wieder öffentlich ist.
|
||||
|
||||
**Geprüft:** PHPUnit 695 grün (PHP 8.4, drei neue Tests und ein erweiterter), `npm test` 41, php-cs-fixer, Twig-Lint, `test:editor` grün. Im Browser: Schrittformular (Luximmo, Pflichtgruppe per Skript gesetzt) zeigt die Meldung und bleibt im Schritt; Editor-Auswahl mit und ohne Korrektur verglichen. Nicht geprüft: PHP 8.2, `test:browser`, MySQL, das Update aus der Verwaltung auf einem Hosting.
|
||||
|
||||
**Bekannte Fehler:** `test:accordion` scheitert schon vor dieser Fassung an der Auswahl im Strukturbaum (Skript kennt die Seitenleiste seit 1.11 nicht). `tests/pilot-fixture.php` migriert die Kopie nicht. Alle übrigen Punkte der Analyse sind offen.
|
||||
|
||||
**Nächster Schritt:** Zweig `release-1.40.1` nach `main`, Tag `v1.40.1`, Releaseeintrag – nach Umuts Freigabe. Danach 1.41 (Stabilität) laut Bericht.
|
||||
|
||||
## Komplettanalyse 1.40.0 (10.11.2026)
|
||||
|
||||
**Anlass:** Umut: „Komplettanalyse – Speedcheck, Bugfix, Oberflächenverbesserung, Optimierung“. Nur Bestandsaufnahme, kein Code geändert. Bericht: `reports/komplettanalyse-2026-11.md` (nicht in Git).
|
||||
|
||||
**Geprüft:** PHPUnit 692 grün (PHP 8.4), `npm test` 41, php-cs-fixer, Twig-Lint, `npm audit` ohne Befund. `composer audit`: 12 Meldungen in 8 Symfony-Paketen (7.4.22, behoben in 7.4.23). Lighthouse 13.4.1 live gegen kant-it.de (noch 1.37.0), vier Seiten, je drei Läufe: Leistung 99–100, Barrierefreiheit 95 auf `/beitraege` und 96 auf der Beitragsseite, sonst 100. Rundgang durch 16 Verwaltungsseiten in 1440 und 390 px mit axe. Code-Durchsicht in vier Bereichen. Nicht geprüft: MySQL, `test:editor`, `test:browser`, Safari, PHP 8.2, Felddaten.
|
||||
|
||||
**Bekannte Fehler (Auswahl, Nummern wie im Bericht):** F1 Formular auf Archivseiten endet in 404; F2 `false` wird als `''` gebunden, auf MySQL scheitert vermutlich jede Formularabsendung (MySQL nicht ausgeführt); F3 `form-steps.js:162` wirft bei Pflicht-Mehrfachauswahl; F4 alte Seiten mit eigenem CSS können mit 500 antworten; F5 zweiter Updateschritt ohne Sperre; E1 lokale `choose` in `editor.js:360` verdeckt die Auswahlfunktion; E2 fünf endgültige Löschaktionen ohne Rückfrage; P1 Bilder laufen durch PHP (live rund 250 ms je Bild); P2 Bildstufen ohne 720 px.
|
||||
|
||||
**Nächster Schritt:** 1.40.1 mit Symfony-Update und den Korrekturen S1, F1, F2, F3, F5, E1, S2, S3 – nach Umuts Freigabe. Vorher klären, ob eine Kundeninstallation auf MySQL läuft.
|
||||
|
||||
## Fassung 1.40.0: Symbol „Haus (Fläche)“, Becker-Paket (10.11.2026)
|
||||
|
||||
**Anlass:** Umut wählte für das Startseiten-Symbol im Becker-Menü einen eigenen Entwurf; der Symbolsatz hat dafür `house` bekommen. Danach: „Release bauen und mir becker als zip paket ablegen“.
|
||||
|
||||
**Release:** Tag `v1.40.0` auf `main`, Releaseeintrag mit Zip (3700 Dateien, 6,1 MB), `.sha256` und `install.php`, gebaut aus sauberem Checkout des Tags (ohne ImmoScout-Plugin); anonymer Download stimmt mit der Prüfsumme überein.
|
||||
|
||||
**Geprüft:** PHPUnit 692 grün (PHP 8.4), `npm test` 41, Twig-Lint, php-cs-fixer. `test:editor` und `test:browser` nicht gelaufen.
|
||||
|
||||
**Becker-Paket:** `var/becker-paket.zip` mit Fassung 1.40.0 neu exportiert, Kopie als `Downloads/becker-paket-1.40.0.zip`. Lässt sich nur auf 1.40.0 oder neuer importieren. Der Plugin-Zustand gehört nicht zum Paket: live nach dem Import unter Plugins „Bildvergleich“ einschalten, sonst bleibt der Vorher/Nachher-Regler leer.
|
||||
|
||||
## Neue Installation „Becker – Just In Time e. Kfm.“, Wiernsheim (10.11.2026)
|
||||
|
||||
**Anlass:** Umut lieferte `Becker-Just-In-Time-Version-5.zip` (ChatGPT-Sites-Entwurf), `Logo_Becker_original.pdf` und sieben Werbemotive mit Logos: „nachbauen, an die CI halten, eigenen Senf dazugeben, Bilder entlogolisieren und von ChatGPT ohne Logos nachbauen lassen“.
|
||||
|
||||
**Umgesetzt:** Die Fotos hat ChatGPT in Umuts Konto neu erzeugt (11 Motive, `var/becker-quelle/ki`, Originale in `var/becker-quelle/original`); die Asbestmotive zeigen dabei Schutzanzug und Atemschutz. `scripts/build-becker-assets.php` stellt das Logo aus dem PDF-Rasterbild frei (Kopf quer, Fuß gestapelt, drei Leistungssymbole, Favicon). `sh scripts/reset-becker.sh [var/becker-quelle]` baut alles neu: Navy #172234 + Logo-Gold #F4B43A, Montserrat/Manrope, Startseite mit Hero, Leistungskarten, Vorher/Nachher-Regler (Plugin „vergleich“), Ablauf, Einblicke-Galerie, Team, Seiten `/gartenbau`, `/zaunbau`, `/asbestsanierung`, `/kontakt` (Formular `anfrage` in zwei Schritten), Impressum, Datenschutz, 404, Paket `var/becker-paket.zip`. Port 8116 (dev) / 8117 (prod), Passwort in `var/becker-zugang.txt`.
|
||||
|
||||
**Geprüft:** Lighthouse im Produktionsmodus für `/`, `/gartenbau`, `/asbestsanierung`, `/kontakt`: Leistung mobil 97–98, Desktop 99–100; Barrierefreiheit, Best Practices, SEO, Agentic Browsing je 100. Ganzseitige Aufnahmen 1440 und 390 px angesehen. Gold-Streifen als Inline-SVG (vorher CSS-Verlauf, sah treppig aus). Nicht geprüft: `test:browser`, Formularversand, Bedienung des Reglers per Tastatur von Hand.
|
||||
|
||||
**Kern:** neues Symbol `house` (geschlossene Hausform, Tür ausgespart) in `public/assets/icons.js`, per `node scripts/build-icons.mjs` in Twig und Validator; Becker nutzt es als Startseiten-Punkt im Menü. `tests/icons.test.mjs` und PHPUnit-Filter „Icon“ (26) grün. Das Becker-Paket braucht deshalb einen Kern mit diesem Symbol (nächstes Release).
|
||||
|
||||
**Offen:** Impressum – „e. Kfm.“ verlangt Registergericht und HRA-Nummer (fehlen); die im Entwurf als USt-IdNr. genannte „48116/36926“ ist eine Steuernummer und steht deshalb nicht drin. E-Mail: Entwurf `info@becker-justintime.de`, Logo-PDF `Becker-Justintime@web.de` – verwendet ist die aus dem Entwurf. Fotos sind KI-Symbolbilder (so im Bildnachweis). Domain/Hosting unbekannt.
|
||||
|
||||
## Fassung 1.39.1: SVG statt Schriftzeichen auf der Website, Luximmo-Immobilien (09.11.2026)
|
||||
|
||||
**Anlass:** Umut: „nur eigene SVGs nutzen“. Bildbetrachter, PDF-Betrachter, Nach-oben-Knopf, Video-Knopf, Menüpfeile, Brotkrumen, Weiterlesen, Blättern und Zitatsterne sind SVG (Masken in `site.css`, sonst eingebettet).
|
||||
|
||||
**Geprüft:** PHPUnit 692 grün (PHP 8.4), `npm test` 41, `test:editor`, `test:browser`, Twig-Lint; Symbole im Browser nachgemessen. Tag `v1.39.1`, Download stimmt mit der Prüfsumme überein.
|
||||
|
||||
**Offen:** Die Verwaltung enthält weiter Schriftzeichen (Editor-Symbole samt Plugin-Bausteinen, Pfeile auf Knöpfen). `var/luximmo-paket.zip` trägt Fassung 1.39.0 und hat die Immobilien-Seite. Im Menü von Luximmo bricht der fünfte Punkt zwischen etwa 1025 und 1130 px um. Immoscout-Plugin aktuell 1.5.2: Paket-Import ersetzt die Mediathek, der Abgleich holt die Bilder danach neu.
|
||||
|
||||
## Kern: Plugins mit PHP-Code; Plugin „ImmoScout-Abgleich“ für DEEG (09.11.2026)
|
||||
|
||||
**Anlass:** Umut: ImmoScout-Objekte im Takt abgleichen, kostenlos per URL, „100 % ein Plugin“, „wie WordPress“.
|
||||
|
||||
**Kern (ADR 127):** `plugin.php` mit `PluginApi`/`PluginRuntime`: Hooks, Aufgaben, Seiten, eigene Tabellen, Verwaltungsbildschirme, Fehler je Plugin eingegrenzt. **Release:** Tag `v1.39.0` auf `main`, Releaseeintrag mit Zip (3694 Dateien, 5,8 MB), `.sha256` und `install.php`, gebaut aus sauberem Stand ohne das ImmoScout-Plugin; anonymer Download stimmt mit der Prüfsumme überein.
|
||||
|
||||
**Geprüft:** PHPUnit 692 grün (PHP 8.4, neu `PluginServerTest`), `npm test` 41, `test:editor`, Twig-Lint, php-cs-fixer. Auf einer Kopie der DEEG-Installation in der Verwaltung: einschalten, einstellen, echter Abgleich (5 Objekte, Bilder, Exposés), Liste, Objektseite 1440 und 390 px ohne waagrechtes Scrollen, Sitemap, Anfrage mit Objektangabe gespeichert, verschwundenes Objekt beim zweiten Lauf inaktiv mit 410. Nicht geprüft: Hosting, MySQL, Mailversand, Lighthouse der Objektseiten; die Abweichung Angebotszahl/gelesene Objekte wurde nicht einzeln simuliert.
|
||||
|
||||
**Nächster Schritt:** DEEG auf 1.39.0 aktualisieren, `var/immoscout-plugin-1.4.0.zip` nach `plugins/` entpacken, einschalten, Portal-Adresse eintragen, abgleichen, Baustein auf `/angebote` setzen. Vorher Zustimmung von DEEG einholen.
|
||||
|
||||
**Plugin:** liegt in `plugins/immoscout` (lokal von Git ausgeschlossen, Auslieferung als ZIP), nicht Teil des Kerns. ImmoScouts `robots.txt` verbietet automatisches Abrufen; das hat Umut entschieden, der Kunde sollte zustimmen. Eine Sperre wird nicht umgangen.
|
||||
|
||||
## Kern: Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026)
|
||||
|
||||
**Anlass:** SEO-Durchsicht bei Luximmo. Neu unter Einstellungen (ADR 126): Art des Unternehmens, Breiten-/Längengrad, Logo aus der Mediathek. Ohne Angaben bleibt die Ausgabe wie bisher, keine Migration.
|
||||
@@ -10,6 +82,10 @@
|
||||
|
||||
Die Ausgabe auf einer Live-Seite braucht mindestens Fassung 1.38.0; 1.37.0 ignoriert die Werte.
|
||||
|
||||
**Release:** Tag `v1.38.0` auf `main`, Releaseeintrag mit Zip (3690 Dateien, 5,8 MB), `.sha256` und `install.php`, gebaut aus sauberem Stand; anonymer Download stimmt mit der Prüfsumme überein. PHPUnit 687 grün unter PHP 8.4, PHP 8.2 nicht gelaufen. Luximmo-Paket mit Fassung 1.38.0 neu exportiert, es lässt sich nur auf 1.38.0 oder neuer importieren.
|
||||
|
||||
**Nächster Schritt:** Luximmo auf 1.38.0 einrichten, Paket importieren, Impressum vervollständigen; übrige Installationen über die Zentrale aktualisieren.
|
||||
|
||||
## Neue Installation „Luximmo Hausverwaltung“, Gaggenau (06.11.2026)
|
||||
|
||||
**Anlass:** Umut lieferte `luximmo-website.zip` (Onepager der Luximmo UG, Eslem Kurt, Hauptstr. 169, 76571 Gaggenau): „in KantSites nachbauen und wie die anderen Ablagen, gerne etwas ausbauen, und wie bei Immo DEEG ein Formular für Immo auf einer separaten Seite“.
|
||||
|
||||
@@ -357,7 +357,7 @@ function renderContent(block, panel) {
|
||||
};
|
||||
// Eine Eigenschaft setzen, die kein Textfeld ist (Symbol, Stellung, Bewegung). Danach wird das
|
||||
// Panel neu gezeichnet, und der Fokus kehrt zu dem Bedienelement zurück, das sie gesetzt hat.
|
||||
const choose = (key, next, version, focus) => {
|
||||
const setProp = (key, next, version, focus) => {
|
||||
history.change(doc => {
|
||||
const current = locate(doc.blocks, selected)?.block;
|
||||
if (!current) return;
|
||||
@@ -370,7 +370,7 @@ function renderContent(block, panel) {
|
||||
document.querySelector(focus)?.focus({ preventScroll: true });
|
||||
};
|
||||
const iconField = (label, value, options = {}) => panel.append(renderIconField({
|
||||
label, value, none: options.none, onChoose: name => choose('icon', name, options.version, '.icon-field-change'),
|
||||
label, value, none: options.none, onChoose: name => setProp('icon', name, options.version, '.icon-field-change'),
|
||||
}));
|
||||
// Zwei bis drei Möglichkeiten nebeneinander statt einer Auswahlliste.
|
||||
const segmented = (label, key, current, options, version) => {
|
||||
@@ -381,7 +381,7 @@ function renderContent(block, panel) {
|
||||
for (const [value, text] of options) {
|
||||
const option = document.createElement('button'); option.type = 'button'; option.dataset[key] = value; option.textContent = text;
|
||||
option.setAttribute('aria-pressed', String(value === current));
|
||||
option.addEventListener('click', () => { if (value !== current) choose(key, value, version, `[data-${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${value}"]`); });
|
||||
option.addEventListener('click', () => { if (value !== current) setProp(key, value, version, `[data-${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${value}"]`); });
|
||||
group.append(option);
|
||||
}
|
||||
wrap.append(caption, group); panel.append(wrap);
|
||||
|
||||
@@ -39,10 +39,6 @@
|
||||
/* latin */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/manrope-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/manrope-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
@@ -50,3 +46,11 @@
|
||||
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:400 600;font-display:swap;src:url(/assets/fonts/instrument-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:400 600;font-display:swap;src:url(/assets/fonts/instrument-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 800;font-display:swap;src:url(/assets/fonts/manrope-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 800;font-display:swap;src:url(/assets/fonts/manrope-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Montserrat';font-style:normal;font-weight:700 800;font-display:swap;src:url(/assets/fonts/montserrat-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Montserrat';font-style:normal;font-weight:700 800;font-display:swap;src:url(/assets/fonts/montserrat-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -159,8 +159,14 @@
|
||||
if (group.closest('[data-inactive]')) continue;
|
||||
const note = group.querySelector('.field-error[data-live]');
|
||||
if (!group.querySelector('input:checked')) {
|
||||
if (!note) group.append(Object.assign(el('p', 'field-error', t('chooseOne', 'Bitte wähle mindestens eine Option.')), { dataset: { live: '' } }));
|
||||
else note.hidden = false;
|
||||
if (!note) {
|
||||
// dataset lässt sich nicht als Ganzes zuweisen: Object.assign warf hier, und „Weiter“
|
||||
// tat ohne Meldung nichts.
|
||||
const hint = el('p', 'field-error', t('chooseOne', 'Bitte wähle mindestens eine Option.'));
|
||||
hint.dataset.live = '';
|
||||
hint.setAttribute('role', 'alert');
|
||||
group.append(hint);
|
||||
} else note.hidden = false;
|
||||
group.classList.add('has-error');
|
||||
group.querySelector('input')?.focus();
|
||||
return false;
|
||||
|
||||
@@ -74,6 +74,7 @@ export const ICON_PATHS = {
|
||||
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
||||
// Haus, Garten und Handwerk
|
||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||
house: 'M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1Z',
|
||||
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
|
||||
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
|
||||
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
|
||||
@@ -128,7 +129,7 @@ export const ICON_LABELS = {
|
||||
mic: 'Mikrofon', book: 'Buch',
|
||||
user: 'Person', people: 'Personen', login: 'Anmelden', lock: 'Schloss', key: 'Schlüssel', shield: 'Schutz',
|
||||
eye: 'Ansehen', 'thumbs-up': 'Daumen hoch', award: 'Auszeichnung', graduation: 'Ausbildung', heart: 'Herz', star: 'Stern',
|
||||
home: 'Haus', building: 'Gebäude', wrench: 'Werkzeug', hammer: 'Hammer', brush: 'Malerrolle', ruler: 'Lineal',
|
||||
home: 'Haus', house: 'Haus (Fläche)', building: 'Gebäude', wrench: 'Werkzeug', hammer: 'Hammer', brush: 'Malerrolle', ruler: 'Lineal',
|
||||
leaf: 'Blatt', tree: 'Baum', sun: 'Sonne', droplet: 'Wasser', flame: 'Flamme', bolt: 'Strom', solar: 'Solar',
|
||||
thermometer: 'Temperatur', snowflake: 'Kälte', car: 'Auto',
|
||||
check: 'Häkchen', 'check-circle': 'Häkchen im Kreis', info: 'Info', help: 'Frage', alert: 'Warnung',
|
||||
@@ -144,7 +145,7 @@ export const ICON_GROUPS = [
|
||||
['Handel & Geld', ['shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase']],
|
||||
['Dateien & Medien', ['document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book']],
|
||||
['Personen & Sicherheit', ['user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star']],
|
||||
['Haus & Handwerk', ['home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
|
||||
['Haus & Handwerk', ['home', 'house', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
|
||||
['Allgemein', ['check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design']],
|
||||
['Soziale Netzwerke', ['facebook', 'instagram', 'whatsapp']],
|
||||
];
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/* Symbole als SVG: Schriftzeichen macht ein Telefon zu Emoji. */
|
||||
const symbol = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${p}"/></svg>`;
|
||||
|
||||
/*
|
||||
* Der Bildbetrachter.
|
||||
*
|
||||
@@ -23,9 +26,9 @@
|
||||
<img class="zoom-image" alt="">
|
||||
<figcaption class="zoom-caption"></figcaption>
|
||||
</figure>
|
||||
<button class="zoom-close" type="button" aria-label="Schließen">×</button>
|
||||
<button class="zoom-prev" type="button" aria-label="Vorheriges Bild">‹</button>
|
||||
<button class="zoom-next" type="button" aria-label="Nächstes Bild">›</button>
|
||||
<button class="zoom-close" type="button" aria-label="Schließen">${symbol('M6 6l12 12M18 6 6 18')}</button>
|
||||
<button class="zoom-prev" type="button" aria-label="Vorheriges Bild">${symbol('M15 5.5 8.5 12l6.5 6.5')}</button>
|
||||
<button class="zoom-next" type="button" aria-label="Nächstes Bild">${symbol('m9 5.5 6.5 6.5L9 18.5')}</button>
|
||||
<p class="zoom-counter" aria-live="polite"></p>
|
||||
`;
|
||||
document.body.append(dialog);
|
||||
@@ -138,7 +141,7 @@
|
||||
<p class="pdf-title" id="pdf-dialog-title"></p>
|
||||
<a class="pdf-action pdf-download" download>Herunterladen</a>
|
||||
<a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</a>
|
||||
<button class="pdf-close" type="button" aria-label="Schließen">×</button>
|
||||
<button class="pdf-close" type="button" aria-label="Schließen">${symbol('M6 6l12 12M18 6 6 18')}</button>
|
||||
</div>
|
||||
<iframe class="pdf-frame" title="PDF-Dokument"></iframe>
|
||||
`;
|
||||
|
||||
+17
-7
@@ -1,3 +1,5 @@
|
||||
/* Symbole als eingebettete SVG-Masken: Schriftzeichen macht ein Telefon zu Emoji. */
|
||||
:root{--kant-svg-unten:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");--kant-svg-oben:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 15 6-6 6 6'/%3E%3C/svg%3E");--kant-svg-rechts:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 5.5 6.5 6.5L9 18.5'/%3E%3C/svg%3E");--kant-svg-pfeil:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5'/%3E%3C/svg%3E");--kant-svg-pfeil-links:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 12H4.5M11 5.5 4.5 12 11 18.5'/%3E%3C/svg%3E");--kant-svg-pause:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 5v14m8-14v14'/%3E%3C/svg%3E");--kant-svg-play:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23000' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E")}
|
||||
@charset "UTF-8";
|
||||
/* Lange Wörter dürfen umbrechen, damit nichts aus der Spalte läuft. Getrennt wird nur im
|
||||
Fließtext: eine Überschrift mit Trennstrich („di-gitale") sieht nach Fehler aus. */
|
||||
@@ -19,14 +21,14 @@ p{hyphens:auto}
|
||||
.menu-list a:hover,.menu-list summary:hover{text-decoration:underline}
|
||||
.menu-list [aria-current=page]{font-weight:700;text-decoration:none}
|
||||
.menu-branch>summary{cursor:pointer;padding:6px 2px;list-style:none}
|
||||
.menu-branch>summary::after{content:' ▾'}
|
||||
.menu-branch[open]>summary::after{content:' ▴'}
|
||||
.menu-branch>summary::after{content:'';display:inline-block;width:.75em;height:.75em;background:currentColor;-webkit-mask:var(--kant-svg-unten) center/contain no-repeat;mask:var(--kant-svg-unten) center/contain no-repeat;margin-left:.3em;vertical-align:-.05em}
|
||||
.menu-branch[open]>summary::after{-webkit-mask-image:var(--kant-svg-oben);mask-image:var(--kant-svg-oben)}
|
||||
.menu-branch-body{display:grid;gap:2px;padding:6px 0 6px 14px}
|
||||
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
|
||||
|
||||
/* Posts */
|
||||
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px}.post-list-karten{grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.post-list-liste{gap:18px}.post-list-liste .post-card{flex-direction:row;align-items:stretch}.post-list-liste .post-image{flex:0 0 34%;max-width:300px}.post-list-liste .post-image img{height:100%;aspect-ratio:auto;min-height:170px}.post-list-liste .post-body{flex:1 1 auto;align-content:center;padding:22px 26px}.post-list-wechsel{gap:0;counter-reset:beitrag}.post-list-wechsel .post-card{position:relative;counter-increment:beitrag;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:0 56px;padding:38px 0;background:transparent;border:0;border-radius:0;overflow:visible}.post-list-wechsel .post-card + .post-card{border-top:1px solid var(--line)}.post-list-wechsel .post-image{border-radius:20px;overflow:hidden;box-shadow:0 18px 46px -22px color-mix(in srgb,var(--ink) 55%,transparent)}.post-list-wechsel .post-image img{aspect-ratio:16/10;border-radius:20px}.post-list-wechsel .post-body{padding:0;display:grid;gap:12px;align-content:center}.post-list-wechsel .post-body::before{content:counter(beitrag,decimal-leading-zero);font-size:13px;font-weight:800;letter-spacing:2px;color:var(--accent)}.post-list-wechsel .post-card h3{font-size:28px;line-height:1.18;letter-spacing:-.5px}.post-list-wechsel .post-excerpt{margin:0;max-width:46ch}.post-list-wechsel .post-meta,.post-list-wechsel .post-terms{margin:0}.post-list-wechsel .post-more{display:inline-flex;align-items:center;gap:8px;margin-top:4px;font-size:15px;font-weight:700;color:var(--accent);text-decoration:none;width:fit-content}
|
||||
.post-more::after{content:'→';transition:transform .2s}
|
||||
.post-more::after{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil) center/contain no-repeat;mask:var(--kant-svg-pfeil) center/contain no-repeat;vertical-align:-.12em;transition:transform .2s}
|
||||
.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:767px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
|
||||
.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px}
|
||||
.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
|
||||
@@ -66,7 +68,9 @@ p{hyphens:auto}
|
||||
.block-quote blockquote p{margin:0;font-size:inherit;line-height:1.65}
|
||||
.block-quote figcaption{font-size:14px;font-weight:600}
|
||||
.block-quote figcaption span{font-weight:400;opacity:.85}
|
||||
.quote-rating{margin:0;letter-spacing:3px}
|
||||
.quote-rating{margin:0}
|
||||
.quote-rating svg{display:inline-block;width:1.1em;height:1.1em;margin-right:2px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linejoin:round}
|
||||
.quote-rating .quote-star-voll{fill:currentColor}
|
||||
.block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px}
|
||||
.block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block}
|
||||
.kant .block-cta .button{background:var(--accent);color:var(--paper);border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,var(--ink))}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
|
||||
@@ -170,7 +174,7 @@ p{hyphens:auto}
|
||||
@media(max-width:767px){.form-summary-list{grid-template-columns:1fr;gap:2px 0}.form-summary-list dd{margin-bottom:8px}}
|
||||
|
||||
.block-breadcrumb ol{list-style:none;display:flex;flex-wrap:wrap;gap:6px 10px;margin:0;padding:0;font-size:13px}
|
||||
.block-breadcrumb li+li::before{content:'›';margin-right:10px;opacity:.6}
|
||||
.block-breadcrumb li+li::before{content:'';display:inline-block;width:.85em;height:.85em;background:currentColor;-webkit-mask:var(--kant-svg-rechts) center/contain no-repeat;mask:var(--kant-svg-rechts) center/contain no-repeat;margin-right:10px;opacity:.6;vertical-align:-.08em}
|
||||
.block-breadcrumb a{text-decoration:none}
|
||||
.block-breadcrumb a:hover{text-decoration:underline}
|
||||
|
||||
@@ -247,8 +251,8 @@ body.consent-open{padding-bottom:320px}
|
||||
.section-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none}
|
||||
.has-video>.section-inner{position:relative;z-index:1}
|
||||
.video-toggle{position:absolute;right:16px;bottom:16px;z-index:2;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:rgba(10,20,35,.72);color:#fff;font:14px/1 system-ui,sans-serif;cursor:pointer}
|
||||
.video-toggle::before{content:"❚❚"}
|
||||
.video-toggle.is-paused::before{content:"▶"}
|
||||
.video-toggle::before{content:'';display:inline-block;width:20px;height:20px;background:currentColor;-webkit-mask:var(--kant-svg-pause) center/contain no-repeat;mask:var(--kant-svg-pause) center/contain no-repeat;display:block;margin:auto}
|
||||
.video-toggle.is-paused::before{-webkit-mask-image:var(--kant-svg-play);mask-image:var(--kant-svg-play)}
|
||||
.video-toggle:focus-visible{outline:3px solid #fff;outline-offset:2px}
|
||||
.video-toggle[hidden]{display:none}
|
||||
.block-html{max-width:100%}
|
||||
@@ -603,6 +607,10 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
|
||||
.zoom-close,.zoom-prev,.zoom-next{position:fixed;display:grid;place-items:center;width:52px;height:52px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:26px;line-height:1;cursor:pointer}
|
||||
.zoom-close:hover,.zoom-prev:hover,.zoom-next:hover{background:rgba(255,255,255,.26)}
|
||||
.zoom-close:focus-visible,.zoom-prev:focus-visible,.zoom-next:focus-visible{outline:3px solid #fff;outline-offset:3px}
|
||||
.zoom-close svg,.zoom-prev svg,.zoom-next svg,.pdf-close svg,.scroll-top svg{display:block;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||
.zoom-close svg,.zoom-prev svg,.zoom-next svg{width:26px;height:26px}
|
||||
.pdf-close svg{width:20px;height:20px}
|
||||
.scroll-top svg{width:46%;height:46%}
|
||||
.zoom-close{top:18px;right:18px}
|
||||
.zoom-prev{left:18px;top:50%;transform:translateY(-50%)}
|
||||
.zoom-next{right:18px;top:50%;transform:translateY(-50%)}
|
||||
@@ -644,3 +652,5 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
|
||||
.tone-ink,.tone-mint{background:transparent;color:#000}
|
||||
[data-step][hidden],.block-form [hidden]{display:block!important}
|
||||
}
|
||||
.post-pager a[rel="prev"]::before{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil-links) center/contain no-repeat;mask:var(--kant-svg-pfeil-links) center/contain no-repeat;margin-right:.35em;vertical-align:-.12em}
|
||||
.post-pager a[rel="next"]::after{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil) center/contain no-repeat;mask:var(--kant-svg-pfeil) center/contain no-repeat;margin-left:.35em;vertical-align:-.12em}
|
||||
@@ -56,14 +56,14 @@ export function initSliders(doc = document) {
|
||||
go(direction > 0 ? (last ? visible[0] : visible[Math.min(visible.length - 1, position + 1)]) : (first ? visible.at(-1) : visible[Math.max(0, position - 1)]));
|
||||
};
|
||||
const manual = action => { playing = false; clearTimeout(timer); update(); action(); };
|
||||
const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1))); previous.className = 'slider-prev';
|
||||
const previous = button('', 'Vorherige Folie', () => manual(() => step(-1))); previous.className = 'slider-prev';
|
||||
const count = doc.createElement('span'); count.className = 'slider-position'; controls.append(count);
|
||||
const next = button('→', 'Nächste Folie', () => manual(() => step(1))); next.className = 'slider-next';
|
||||
const next = button('', 'Nächste Folie', () => manual(() => step(1))); next.className = 'slider-next';
|
||||
const dots = [];
|
||||
if (slider.classList.contains('slider-dots')) {
|
||||
const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group);
|
||||
slides.forEach((slide, i) => {
|
||||
const dot = button('●', `Folie ${i + 1} anzeigen`, () => manual(() => go(i))); group.append(dot); dots.push(dot);
|
||||
const dot = button('', `Folie ${i + 1} anzeigen`, () => manual(() => go(i))); group.append(dot); dots.push(dot);
|
||||
});
|
||||
}
|
||||
const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Bereitet Logo und Fotos von „Becker – Just In Time e. Kfm.“ für die Mediathek des Nachbaus vor.
|
||||
*
|
||||
* Quellordner (var/becker-quelle):
|
||||
* - logo-original.png: das Rasterbild aus Logo_Becker_original.pdf (1536 × 1024, Navy-Grund #172234),
|
||||
* - ki/*.png: die von ChatGPT ohne Logos neu erzeugten Fotos (10.11.2026, nach Umuts Vorlagen).
|
||||
*
|
||||
* Daraus entstehen im Zielordner:
|
||||
* - logo.png: Bagger-Signet links, „BECKER / JUST IN TIME e. Kfm.“ rechts – für den Kopf,
|
||||
* - logo-gross.png: das Logo gestapelt mit den drei Leistungssymbolen – für den Fuß,
|
||||
* - favicon.png: das Bagger-Signet auf Navy, 192 × 192,
|
||||
* - je Foto eine WebP-Datei (Startbild 1920 px, sonst 1600 px breit).
|
||||
*
|
||||
* Der Navy-Grund des Logos wird weich freigestellt: Deckkraft aus dem Farbabstand zum Grund,
|
||||
* die Kantenfarbe zurückgerechnet. So passt das Logo auf jede dunkle Fläche.
|
||||
*
|
||||
* Aufruf: php scripts/build-becker-assets.php <Quellordner> <Zielordner>
|
||||
*/
|
||||
$quelle = rtrim($argv[1] ?? '', '/\\');
|
||||
$ziel = rtrim($argv[2] ?? '', '/\\');
|
||||
if ('' === $quelle || '' === $ziel || !is_file($quelle.'/logo-original.png')) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/build-becker-assets.php <Ordner mit logo-original.png und ki/> <Zielordner>\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
|
||||
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$logo = imagecreatefrompng($quelle.'/logo-original.png');
|
||||
$grund = [23, 34, 52];
|
||||
|
||||
/** Ein Ausschnitt des Logos mit freigestelltem Navy-Grund. */
|
||||
$freistellen = static function (int $x, int $y, int $b, int $h) use ($logo, $grund): GdImage {
|
||||
$bild = imagecreatetruecolor($b, $h);
|
||||
imagealphablending($bild, false);
|
||||
imagesavealpha($bild, true);
|
||||
for ($j = 0; $j < $h; ++$j) {
|
||||
for ($i = 0; $i < $b; ++$i) {
|
||||
$c = imagecolorat($logo, $x + $i, $y + $j);
|
||||
$p = [$c >> 16 & 0xFF, $c >> 8 & 0xFF, $c & 0xFF];
|
||||
$abstand = max(abs($p[0] - $grund[0]), abs($p[1] - $grund[1]), abs($p[2] - $grund[2]));
|
||||
$a = min(1.0, max(0.0, ($abstand - 6) / 90));
|
||||
if ($a <= 0.0) {
|
||||
imagesetpixel($bild, $i, $j, imagecolorallocatealpha($bild, 0, 0, 0, 127));
|
||||
continue;
|
||||
}
|
||||
$f = array_map(static fn (int $k): int => (int) max(0, min(255, round($grund[$k] + ($p[$k] - $grund[$k]) / $a))), [0, 1, 2]);
|
||||
imagesetpixel($bild, $i, $j, imagecolorallocatealpha($bild, $f[0], $f[1], $f[2], (int) round(127 * (1 - $a))));
|
||||
}
|
||||
}
|
||||
|
||||
return $bild;
|
||||
};
|
||||
|
||||
/** Verkleinert mit Durchsichtigkeit. */
|
||||
$verkleinern = static function (GdImage $bild, int $breite): GdImage {
|
||||
$hoehe = (int) round(imagesy($bild) * $breite / imagesx($bild));
|
||||
$neu = imagecreatetruecolor($breite, $hoehe);
|
||||
imagealphablending($neu, false);
|
||||
imagesavealpha($neu, true);
|
||||
imagefill($neu, 0, 0, imagecolorallocatealpha($neu, 0, 0, 0, 127));
|
||||
imagecopyresampled($neu, $bild, 0, 0, 0, 0, $breite, $hoehe, imagesx($bild), imagesy($bild));
|
||||
|
||||
return $neu;
|
||||
};
|
||||
|
||||
// Bänder im Original (gemessen): Signet y 15–455 / x 360–1196, „BECKER“ y 466–636,
|
||||
// „JUST IN TIME e. Kfm.“ mit Linien y 647–701 / x 162–1382, Leistungssymbole y 722–897.
|
||||
$signet = $freistellen(352, 8, 852, 456);
|
||||
$schrift = $freistellen(154, 458, 1236, 252);
|
||||
|
||||
// Querformat für den Kopf: Signet links (so hoch wie die Schrift plus etwas), Schrift rechts.
|
||||
$sh = 330;
|
||||
$sb = (int) round(852 * $sh / 456);
|
||||
$quer = imagecreatetruecolor($sb + 36 + 1236, $sh);
|
||||
imagealphablending($quer, false);
|
||||
imagesavealpha($quer, true);
|
||||
imagefill($quer, 0, 0, imagecolorallocatealpha($quer, 0, 0, 0, 127));
|
||||
imagealphablending($quer, true);
|
||||
imagecopyresampled($quer, $signet, 0, 0, 0, 0, $sb, $sh, 852, 456);
|
||||
imagecopy($quer, $schrift, $sb + 36, (int) round(($sh - 252) / 2) + 10, 0, 0, 1236, 252);
|
||||
imagealphablending($quer, false);
|
||||
imagepng($verkleinern($quer, 760), $ziel.'/logo.png', 9);
|
||||
echo 'logo.png: 760 × '.(int) round($sh * 760 / imagesx($quer))."\n";
|
||||
|
||||
$gross = $freistellen(100, 8, 1336, 898);
|
||||
imagepng($verkleinern($gross, 560), $ziel.'/logo-gross.png', 9);
|
||||
echo "logo-gross.png: 560 px breit\n";
|
||||
|
||||
// Die drei Leistungssymbole des Logos (Blätter, Zaun, Dach) einzeln, quadratisch, 128 px.
|
||||
foreach (['symbol-garten' => [250, 716, 186, 120], 'symbol-zaun' => [679, 716, 176, 124], 'symbol-asbest' => [1071, 716, 226, 120]] as $name => [$x, $y, $b, $h]) {
|
||||
$teil = $freistellen($x, $y, $b, $h);
|
||||
$q = max($b, $h);
|
||||
$quadrat = imagecreatetruecolor(128, 128);
|
||||
imagealphablending($quadrat, false);
|
||||
imagesavealpha($quadrat, true);
|
||||
imagefill($quadrat, 0, 0, imagecolorallocatealpha($quadrat, 0, 0, 0, 127));
|
||||
$f = 128 / $q;
|
||||
imagecopyresampled($quadrat, $teil, (int) round((128 - $b * $f) / 2), (int) round((128 - $h * $f) / 2), 0, 0, (int) round($b * $f), (int) round($h * $f), $b, $h);
|
||||
imagepng($quadrat, $ziel.'/'.$name.'.png', 9);
|
||||
echo "$name.png: 128 × 128\n";
|
||||
}
|
||||
|
||||
// Seitensymbol: Signet mittig auf Navy.
|
||||
$fav = imagecreatetruecolor(192, 192);
|
||||
imagefill($fav, 0, 0, imagecolorallocate($fav, ...$grund));
|
||||
imagecopyresampled($fav, $logo, 14, 22 + (int) round((148 - 164 * 440 / 836) / 2), 360, 15, 164, (int) round(164 * 440 / 836), 836, 440);
|
||||
imagepng($fav, $ziel.'/favicon.png', 9);
|
||||
echo "favicon.png: 192 × 192\n";
|
||||
|
||||
// Fotos: ChatGPT liefert PNG; als WebP auf Zielbreite.
|
||||
foreach (['haus' => 1920, 'team' => 1600, 'zaun' => 1600, 'zauntor' => 1600, 'asbest' => 1600, 'dach' => 1600,
|
||||
'bagger' => 1600, 'terrasse' => 1600, 'zisterne' => 1600, 'vorher' => 1600, 'nachher' => 1600] as $name => $breite) {
|
||||
$pfad = $quelle.'/ki/'.$name.'.png';
|
||||
if (!is_file($pfad)) {
|
||||
fwrite(STDERR, "Foto fehlt: $pfad\n");
|
||||
exit(1);
|
||||
}
|
||||
$bild = imagecreatefrompng($pfad);
|
||||
$breite = min($breite, imagesx($bild));
|
||||
$hoehe = (int) round(imagesy($bild) * $breite / imagesx($bild));
|
||||
$neu = imagecreatetruecolor($breite, $hoehe);
|
||||
imagecopyresampled($neu, $bild, 0, 0, 0, 0, $breite, $hoehe, imagesx($bild), imagesy($bild));
|
||||
imagewebp($neu, $ziel.'/'.$name.'.webp', 80);
|
||||
echo "$name.webp: $breite × $hoehe\n";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
# Wirft die Becker-Installation (Becker – Just In Time e. Kfm., Wiernsheim) weg und baut sie neu auf.
|
||||
# Nur fuer den Aufbau gedacht.
|
||||
# Erstes Argument (optional): Quellordner mit logo-original.png (Rasterbild aus Logo_Becker_original.pdf)
|
||||
# und ki/*.png (die ohne Logos neu erzeugten Fotos). Dann entstehen Logo, Symbole und WebP-Fotos neu.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/becker.db"
|
||||
export MEDIA_DIRECTORY="$root/var/becker-media"
|
||||
assets="$root/var/becker-assets"
|
||||
if [ -n "$1" ]; then
|
||||
"$php" scripts/build-becker-assets.php "$1" "$assets"
|
||||
fi
|
||||
if [ ! -f "$assets/haus.webp" ] || [ ! -f "$assets/logo.png" ]; then
|
||||
echo "Bilder fehlen: erst mit dem Quellordner (z. B. var/becker-quelle) als Argument aufrufen." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$root/var/becker.db"
|
||||
rm -rf "$root/var/becker-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
"$php" -d memory_limit=512M scripts/seed-becker.php "$assets"
|
||||
# Manrope und Montserrat vom eigenen Server; der Abruf ergaenzt fonts.css nur.
|
||||
if [ -f "$root/var/cacert.pem" ]; then
|
||||
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
|
||||
else
|
||||
"$php" bin/console cms:fonts:fetch
|
||||
fi
|
||||
# Das Inhaltspaket fuer eine Live-Seite (ADR 118): dort unter System -> Inhalte uebertragen importieren.
|
||||
rm -f "$root/var/becker-paket.zip"
|
||||
"$php" bin/console cms:paket:export --ziel="$root/var/becker-paket.zip"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -452,8 +452,8 @@ $kopf = [
|
||||
]],
|
||||
// Goldene Linie, die beim Überfahren wächst; das Klappmenü liegt als Fläche
|
||||
// unter dem ganzen Kopf.
|
||||
'css' => "& .menu-list{gap:4px 28px;align-items:center}\n"
|
||||
."& .menu-list a{position:relative;padding:12px 0;text-decoration:none}\n"
|
||||
'css' => "& .menu-list{gap:4px clamp(10px,calc(4.2vw - 32px),28px);align-items:center}\n"
|
||||
."& .menu-list a{position:relative;padding:12px 0;text-decoration:none;font-size:clamp(13px,1.05vw,15px)}\n"
|
||||
."& .menu-list a::after{content:'';position:absolute;left:0;right:100%;bottom:6px;height:1.5px;background:#BDA076;transition:right .25s ease}\n"
|
||||
."& .menu-list a:hover::after{right:0}\n"
|
||||
."& .menu-burger summary{width:46px;height:46px;display:flex;align-items:center;justify-content:center;padding:0}\n"
|
||||
@@ -501,7 +501,7 @@ $fuss = [
|
||||
]),
|
||||
$column('fuss-seiten', [
|
||||
$kleinzeile('fuss-seiten-titel', 'Leistungen', '@gold-dunkel', '8px'),
|
||||
$fussText('fuss-seiten-text', '<a href="/#leistungen">Immobilienverkauf</a><br><a href="/#leistungen">WEG-Verwaltung</a><br><a href="/#leistungen">Mietverwaltung</a><br><a href="/#leistungen">Sondereigentum</a><br><a href="/verwaltungsanfrage">Verwaltungsanfrage</a><br><a href="/immobilienbewertung">Immobilienbewertung</a>'),
|
||||
$fussText('fuss-seiten-text', '<a href="/#leistungen">Immobilienverkauf</a><br><a href="/#leistungen">WEG-Verwaltung</a><br><a href="/#leistungen">Mietverwaltung</a><br><a href="/#leistungen">Sondereigentum</a><br><a href="/verwaltungsanfrage">Verwaltungsanfrage</a><br><a href="/immobilienbewertung">Immobilienbewertung</a><br><a href="/immobilien">Immobilien</a>'),
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '40px', 'padding' => ['top' => '56px', 'bottom' => '40px']],
|
||||
@@ -649,7 +649,10 @@ $startseite = [
|
||||
$section('leistungen-bereich', [
|
||||
$zeile('leistungen-kopf', [
|
||||
$heading('leistungen-titel', 'Leistungen', 2, $h2Stil),
|
||||
$text('leistungen-link', '<a href="/verwaltungsanfrage">Unverbindlich anfragen →</a>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '500', 'margin' => ['bottom' => '0px']]], $linkCss),
|
||||
$zeile('leistungen-link-zeile', [
|
||||
$text('leistungen-link', '<a href="/verwaltungsanfrage">Unverbindlich anfragen</a>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '500', 'margin' => ['bottom' => '0px']]], $linkCss),
|
||||
$symbol('leistungen-link-pfeil', 'arrow-right', 16, ['desktop' => ['color' => '@tinte']]),
|
||||
], '6px'),
|
||||
], '16px', ['justifyContent' => 'space-between', 'alignItems' => 'baseline', 'flexWrap' => 'wrap', 'margin' => ['bottom' => '56px']], ['margin' => ['bottom' => '36px']]),
|
||||
$column('leistungen-raster', [
|
||||
$leistung('leistung-verkauf', $medien['poster'], 'Offener Wohn- und Essbereich mit Treppe, Holzlamellen und großer Glasfront', 'Immobilienverkauf', 'Professionelle Vermarktung, marktgerechte Bewertung und persönliche Begleitung bis zum erfolgreichen Verkauf.', [
|
||||
@@ -816,7 +819,11 @@ $startseite = [
|
||||
]),
|
||||
$column('kontakt-mehr', [
|
||||
$kleinzeile('kontakt-mehr-titel', 'Direkt', '@gold-dunkel', '8px'),
|
||||
$text('kontakt-mehr-text', '<a href="'.$tel.'">'.$telefon.'</a><br><a href="mailto:'.$mail.'">'.$mail.'</a><br>Für ein Angebot:<br><a href="/verwaltungsanfrage">Verwaltungsanfrage →</a>', ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]], $linkCss),
|
||||
$text('kontakt-mehr-text', '<a href="'.$tel.'">'.$telefon.'</a><br><a href="mailto:'.$mail.'">'.$mail.'</a><br>Für ein Angebot:', ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]], $linkCss),
|
||||
$zeile('kontakt-mehr-zeile', [
|
||||
$text('kontakt-mehr-link', '<a href="/verwaltungsanfrage">Verwaltungsanfrage</a>', ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]], $linkCss),
|
||||
$symbol('kontakt-mehr-pfeil', 'arrow-right', 16, ['desktop' => ['color' => '@tinte']]),
|
||||
], '6px'),
|
||||
]),
|
||||
], '56px', ['alignItems' => 'flex-start', 'flexWrap' => 'wrap', 'rowGap' => '32px']),
|
||||
]),
|
||||
@@ -959,6 +966,21 @@ $hinweis = static fn (string $id, string $titel, array $punkte) => $column($id,
|
||||
// Die Bildkarten einer Auswahl: Bild oben, gerundet, dunkler Rand bei Wahl.
|
||||
$kartenCss = "\n& .form-choice-grid{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px}\n& .form-choice-card label{border:1.5px solid #E6DED2;border-radius:20px;background:#fff;padding:10px 10px 14px;font-weight:500;font-size:15px;color:#141414}\n& .form-choice-card label:hover{border-color:#BDA076}\n& .form-choice-card input:checked+label{border-color:#141414;box-shadow:0 0 0 1.5px #141414}\n& .form-choice-card input{accent-color:#141414;top:20px;left:20px;width:20px;height:20px;min-height:0;padding:0;margin:0}\n& .form-choice-card input:focus{outline:none}\n& .form-choice-picture{border-radius:12px}";
|
||||
|
||||
// Die Objekte kommen aus dem Plugin „ImmoScout-Abgleich“; der Baustein trägt das Luximmo-Aussehen.
|
||||
// Ohne eingeschaltetes Plugin bleibt die Seite erreichbar, nur die Liste fehlt.
|
||||
$anlegen('immobilien', $einblenden([
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Immobilien',
|
||||
'description' => 'Aktuelle Immobilienangebote von Luximmo in Gaggenau und Umgebung, Baden-Baden und im Schwarzwald.',
|
||||
'blocks' => [
|
||||
$seitenkopf('immobilien', 'Aktuelle Angebote', 'Unsere Immobilien.', 'Wohnungen und Häuser, die wir aktuell in Gaggenau und Umgebung, Baden-Baden und im Schwarzwald anbieten.'),
|
||||
$section('immobilien', [
|
||||
['id' => 'immobilien-liste', 'type' => 'immoscout.liste',
|
||||
'props' => ['art' => 'alle', 'darstellung' => 'kacheln', 'spalten' => '3', 'spaltenTablet' => '2', 'spaltenMobil' => '1', 'reihen' => 0, 'anzahl' => 0, 'bild' => 'breit', 'abstand' => 'normal', 'ausrichtung' => 'links', 'zeigeArt' => true, 'zeigeOrt' => true, 'zeigeFakten' => true],
|
||||
'css' => "& .immoscout-karte{border:0;border-radius:24px;overflow:hidden;background:#F7F4EF}\n& .immoscout-karte-text{padding:24px 26px 28px}\n& .immoscout-karte h2{font-size:22px;font-weight:500;letter-spacing:-0.015em}\n& .immoscout-art{font-size:13px;font-weight:500;letter-spacing:0.04em;color:#6E5830}\n& .immoscout-ort{color:#5A554D;opacity:1}\n& .immoscout-karte dt{color:#5A554D;opacity:1}\n& .immoscout-karte dd{font-weight:500}\n& .immoscout-leer{border:0;border-radius:24px;background:#F7F4EF}\n& .immoscout-leer h2{font-weight:500;letter-spacing:-0.02em}\n& .immoscout-leer p{color:#5A554D}"],
|
||||
], $abschnitt('0px', '160px'), null, '', 'wide'),
|
||||
],
|
||||
], ['immobilien-liste' => ['rise']]), 'Immobilien in Gaggenau, Baden-Baden und im Schwarzwald | LUXIMMO', $medien['miet']['id']);
|
||||
|
||||
$anlegen('immobilienbewertung', $einblenden([
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Immobilienbewertung',
|
||||
'description' => 'Was ist Ihre Immobilie wert? Luximmo schätzt Wohnungen, Häuser, Mehrfamilienhäuser und Grundstücke in Gaggenau und Umgebung – kostenlos und unverbindlich.',
|
||||
@@ -1093,6 +1115,7 @@ $anker = static fn (string $label, string $anker): array => ['label' => $label,
|
||||
$menus->save('header', 'Hauptnavigation', [
|
||||
$anker('Leistungen', 'leistungen'),
|
||||
$anker('Über uns', 'ueber-uns'),
|
||||
['label' => 'Immobilien', 'type' => 'page', 'pageId' => $seiteId('immobilien'), 'children' => []],
|
||||
['label' => 'Bewertung', 'type' => 'page', 'pageId' => $seiteId('immobilienbewertung'), 'children' => []],
|
||||
['label' => 'Anfrage', 'type' => 'page', 'pageId' => $seiteId('verwaltungsanfrage'), 'children' => []],
|
||||
], 0);
|
||||
|
||||
@@ -16,7 +16,7 @@ final class DocumentValidator
|
||||
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
|
||||
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
|
||||
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
|
||||
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design', 'facebook', 'instagram', 'whatsapp'];
|
||||
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'house', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design', 'facebook', 'instagram', 'whatsapp'];
|
||||
/** Das Symbol eines Buttons, wenn keins gewählt ist: der Pfeil, den jeder Button vor v25 hatte. */
|
||||
public const BUTTON_DEFAULT_ICON = 'arrow-up-right';
|
||||
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
||||
|
||||
@@ -707,7 +707,10 @@ final class FormRepository
|
||||
'id' => $id, 'form_id' => $formId,
|
||||
'data' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'sender_hash' => $senderHash, 'created_at' => $now,
|
||||
'consent_at' => $consent ? $now : null, 'delivered' => false,
|
||||
'consent_at' => $consent ? $now : null,
|
||||
// Als Zahl: ohne Typangabe wird false als leere Zeichenkette gebunden, und MySQL im
|
||||
// Strict-Modus lehnt die für eine Zahlenspalte ab. SQLite nimmt beides.
|
||||
'delivered' => 0,
|
||||
]);
|
||||
|
||||
return $id;
|
||||
@@ -715,7 +718,7 @@ final class FormRepository
|
||||
|
||||
public function markDelivered(string $submissionId): void
|
||||
{
|
||||
$this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [true, $submissionId]);
|
||||
$this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [1, $submissionId]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -190,6 +190,12 @@ final class PageRepository
|
||||
);
|
||||
}
|
||||
|
||||
/** Ob eine Seite eine veröffentlichte Fassung trägt - auch wenn sie im Papierkorb liegt. */
|
||||
public function hasPublication(string $pageId): bool
|
||||
{
|
||||
return false !== $this->db->fetchOne('SELECT id FROM page WHERE id = ? AND website_id = ? AND published_revision_id IS NOT NULL', [$pageId, 'default']);
|
||||
}
|
||||
|
||||
public function trashPage(string $pageId, int $expectedVersion): int
|
||||
{
|
||||
return $this->db->transactional(function () use ($pageId, $expectedVersion): int {
|
||||
|
||||
@@ -332,13 +332,20 @@ final class EditorController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
// Der Papierkorb lässt die veröffentlichte Fassung stehen: eine Seite, die online war, ist
|
||||
// nach dem Wiederherstellen sofort wieder online. Das entscheidet, wer veröffentlichen
|
||||
// darf (ADR 116) - wie beim Weg in den Papierkorb.
|
||||
$live = $this->pages->hasPublication($id);
|
||||
if ($live && !$this->isGranted('ROLE_PUBLISHER')) {
|
||||
return $this->back('Diese Seite war veröffentlicht und wäre nach dem Wiederherstellen sofort wieder öffentlich. Das macht, wer veröffentlichen darf.');
|
||||
}
|
||||
try {
|
||||
$this->pages->restorePage($id);
|
||||
} catch (InvalidPage $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Die Seite ist zurück. Sie ist erst nach dem Veröffentlichen wieder öffentlich.', 'success');
|
||||
return $this->back($live ? 'Die Seite ist zurück und wieder öffentlich.' : 'Die Seite ist zurück. Sie ist ein Entwurf, bis sie veröffentlicht wird.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/revisions/{revision}', name: 'admin_restore_revision', requirements: ['revision' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Turnstile;
|
||||
use App\Ops\MailCheck;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -33,6 +34,7 @@ final class FormController extends AbstractController
|
||||
private string $secret,
|
||||
private string $siteOrigin,
|
||||
private Languages $languages,
|
||||
private PluginRuntime $plugins,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -41,7 +43,12 @@ final class FormController extends AbstractController
|
||||
{
|
||||
$form = $this->forms->findBySlug($slug);
|
||||
$path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath();
|
||||
$page = $this->pages->findPublished($path);
|
||||
// Ein Archiv (Beiträge, Kategorie, Schlagwort) ist keine gespeicherte Seite, trägt aber Kopf
|
||||
// und Fuß wie jede andere - und damit oft das Kontaktformular. Ohne diese Ausnahme endete
|
||||
// das Absenden dort in einer 404, und die Anfrage war weg.
|
||||
$archive = 1 === preg_match(PageController::ARCHIVE_PATH, $path);
|
||||
// Ein Formular kann auch auf der Seite eines Plugins stehen (ADR 127).
|
||||
$page = $archive ? ['id' => null, 'path' => $path] : ($this->pages->findPublished($path) ?? $this->plugins->page('/'.$path));
|
||||
if (null === $form || null === $page) {
|
||||
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
|
||||
}
|
||||
@@ -62,7 +69,8 @@ final class FormController extends AbstractController
|
||||
$language = $request->request->getString('language');
|
||||
$translatedPath = null;
|
||||
if ($this->languages->isExtra($language)) {
|
||||
$translatedPath = $this->pages->translatedPaths($page['id'])[$language] ?? null;
|
||||
// Archive heißen in jeder Sprache gleich und stehen nur hinter der Sprache.
|
||||
$translatedPath = $archive ? $path : ($this->pages->translatedPaths($page['id'])[$language] ?? null);
|
||||
}
|
||||
$language = null === $translatedPath ? null : $language;
|
||||
|
||||
@@ -77,8 +85,11 @@ final class FormController extends AbstractController
|
||||
return $this->renderPage($request, $page, $form, ['errors' => ['_form' => 'Gerade gehen sehr viele Anfragen ein. Bitte versuchen Sie es später erneut oder melden Sie sich telefonisch.'], 'values' => $checked['values']], null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
|
||||
}
|
||||
if (!$silentlyDropped) {
|
||||
// Angaben eines Plugins zur Seite, von der die Anfrage kommt: Name => Text.
|
||||
$extra = array_filter((array) $this->plugins->applyFilters('form.context', [], $form, $path), 'is_string');
|
||||
$checked['values'] += $extra;
|
||||
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
|
||||
if ($this->deliver($form, $checked['values'])) {
|
||||
if ($this->deliver($form, $checked['values'], $extra)) {
|
||||
$this->forms->markDelivered($submission);
|
||||
}
|
||||
}
|
||||
@@ -101,9 +112,12 @@ final class FormController extends AbstractController
|
||||
return $age >= FormRepository::MIN_SECONDS && $age <= FormRepository::MAX_SECONDS;
|
||||
}
|
||||
|
||||
private function deliver(array $form, array $values): bool
|
||||
private function deliver(array $form, array $values, array $extra = []): bool
|
||||
{
|
||||
$lines = [];
|
||||
foreach ($extra as $name => $text) {
|
||||
$lines[] = $name.': '.$text;
|
||||
}
|
||||
foreach ($form['fields'] as $field) {
|
||||
// Ein Schritt ist eine Überschrift in der Post; ein Feld, das nicht galt, bleibt weg.
|
||||
if ('step' === $field['type']) {
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Content\Theme;
|
||||
use App\Content\Translations;
|
||||
use App\Content\Turnstile;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -29,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins, private Languages $languages)
|
||||
public function __construct(private PageRepository $pages, private PluginRuntime $runtime, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins, private Languages $languages)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -48,6 +49,9 @@ final class PageController extends AbstractController
|
||||
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
|
||||
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,4}';
|
||||
|
||||
/** Die Adressen der Archive, ohne Sprache davor: Beiträge, eine Kategorie, ein Schlagwort. */
|
||||
public const ARCHIVE_PATH = '#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D';
|
||||
|
||||
#[Route('/', name: 'home', methods: ['GET'])]
|
||||
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
|
||||
public function page(Request $request, string $path = ''): Response
|
||||
@@ -59,6 +63,11 @@ final class PageController extends AbstractController
|
||||
if ('' !== $path && $this->languages->isExtra($segments[0])) {
|
||||
return $this->translatedPage($request, $segments[0], $segments[1] ?? '');
|
||||
}
|
||||
// Nur der Formularversand kommt mit einer Archivadresse hier an (die Route selbst schließt
|
||||
// sie aus): er zeigt das Archiv mit den Meldungen des Formulars noch einmal.
|
||||
if (1 === preg_match(self::ARCHIVE_PATH, $path, $archiv)) {
|
||||
return $this->renderArchive($request, str_starts_with($path, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null));
|
||||
}
|
||||
// Mehr als vier Ebenen gibt es nur mit einer Sprache davor.
|
||||
if (substr_count($path, '/') >= PageRepository::MAX_DEPTH) {
|
||||
return $this->notFound($request);
|
||||
@@ -78,6 +87,10 @@ final class PageController extends AbstractController
|
||||
if (null !== $route) {
|
||||
return $this->pluginRoute($request, $route);
|
||||
}
|
||||
$pluginPage = $this->runtime->page('/'.$path);
|
||||
if (null !== $pluginPage) {
|
||||
return $this->renderPage($request, $pluginPage, $path, $pluginPage['status']);
|
||||
}
|
||||
$redirect = $this->redirects->find($path);
|
||||
if (null !== $redirect && 301 === $redirect['status']) {
|
||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||
@@ -106,7 +119,7 @@ final class PageController extends AbstractController
|
||||
return $this->shell($this->redirect('/'.$locale, 301));
|
||||
}
|
||||
// Die Archive gibt es in jeder Sprache unter derselben Adresse mit vorangestellter Sprache.
|
||||
if (1 === preg_match('#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D', $rest, $archiv)) {
|
||||
if (1 === preg_match(self::ARCHIVE_PATH, $rest, $archiv)) {
|
||||
$this->translation = ['locale' => $locale, 'map' => $map];
|
||||
|
||||
return $this->renderArchive($request, str_starts_with($rest, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null));
|
||||
@@ -928,7 +941,9 @@ final class PageController extends AbstractController
|
||||
// Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Blätterverweise. Vorher hatte es eine
|
||||
// eigene Vorlage, die von alledem nur die Palette kannte.
|
||||
$kopf = [
|
||||
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => $heading, 'level' => 1]],
|
||||
// Ab Schemaversion 12 wird der Text einer Überschrift als Markup gedruckt. Der Name
|
||||
// einer Kategorie ist aber freier Text, den niemand gefiltert hat - also maskieren.
|
||||
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => htmlspecialchars($heading, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), 'level' => 1]],
|
||||
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? $t('Beitrag') : $t('Beiträge'))]],
|
||||
];
|
||||
if (null !== $term) {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Ops\Scheduler;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -14,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class PluginAdminController extends AbstractController
|
||||
{
|
||||
public function __construct(private PluginRegistry $plugins)
|
||||
public function __construct(private PluginRegistry $plugins, private PluginRuntime $runtime, private Scheduler $scheduler)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -24,7 +26,12 @@ final class PluginAdminController extends AbstractController
|
||||
// Ein Plugin bringt Code auf die Website - das schaltet nur ein Administrator.
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
return $this->render('admin/plugins.html.twig', ['plugins' => $this->plugins->all(), 'directory' => $this->plugins->directory()]);
|
||||
$admin = [];
|
||||
foreach ($this->plugins->all() as $name => $plugin) {
|
||||
$admin[$name] = $this->runtime->admin($name);
|
||||
}
|
||||
|
||||
return $this->render('admin/plugins.html.twig', ['plugins' => $this->plugins->all(), 'directory' => $this->plugins->directory(), 'admin' => $admin, 'runtimeErrors' => $this->runtime->errors()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/plugins/{name}/{state}', name: 'admin_plugin_toggle', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'state' => 'an|aus'], methods: ['POST'])]
|
||||
@@ -36,11 +43,13 @@ final class PluginAdminController extends AbstractController
|
||||
}
|
||||
try {
|
||||
if ('an' === $state) {
|
||||
$this->plugins->activate($name);
|
||||
$this->runtime->activate($name);
|
||||
$this->scheduler->pluginsChanged();
|
||||
|
||||
return $this->back(sprintf('„%s“ ist eingeschaltet. Seine Bausteine stehen jetzt im Editor unter „Erweiterungen“.', $this->plugins->all()[$name]['title']), 'success');
|
||||
}
|
||||
$this->plugins->deactivate($name);
|
||||
$this->runtime->deactivate($name);
|
||||
$this->scheduler->pluginsChanged();
|
||||
|
||||
return $this->back(sprintf('„%s“ ist ausgeschaltet. Seine Bausteine bleiben in den Seiten gespeichert, werden aber nicht mehr dargestellt.', $this->plugins->all()[$name]['title']), 'success');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
@@ -64,6 +73,41 @@ final class PluginAdminController extends AbstractController
|
||||
return $this->back(sprintf('Die Einstellungen von „%s“ wurden gespeichert.', $this->plugins->all()[$name]['title']), 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/plugins/{name}/seite/{key}', name: 'admin_plugin_page', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'key' => '[a-z][a-z0-9-]{0,40}'], methods: ['GET'])]
|
||||
public function page(string $name, string $key): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$admin = $this->runtime->admin($name);
|
||||
$page = $admin['pages'][$key] ?? throw $this->createNotFoundException();
|
||||
|
||||
return $this->render('admin/plugin-page.html.twig', ['plugin' => $this->plugins->all()[$name], 'page' => $page, 'actions' => $admin['actions'], 'data' => ($page['callback'])()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/plugins/{name}/aktion/{key}', name: 'admin_plugin_action', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'key' => '[a-z][a-z0-9-]{0,40}'], methods: ['POST'])]
|
||||
public function action(Request $request, string $name, string $key): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('plugins', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$admin = $this->runtime->admin($name);
|
||||
$action = $admin['actions'][$key] ?? throw $this->createNotFoundException();
|
||||
try {
|
||||
if (is_string($action['handler'])) {
|
||||
$result = $this->scheduler->runNow(PluginRuntime::taskName($name, $action['handler']));
|
||||
[$type, $message] = null === $result ? ['error', 'Die Aufgabe läuft gerade schon.'] : ['ok' === $result['status'] ? 'success' : 'error', $result['message']];
|
||||
} else {
|
||||
[$type, $message] = ['success', (string) ($action['handler'])()];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
[$type, $message] = ['error', $e->getMessage()];
|
||||
}
|
||||
$this->addFlash($type, $message);
|
||||
$back = $request->request->getString('zurueck');
|
||||
|
||||
return isset($admin['pages'][$back]) ? $this->redirectToRoute('admin_plugin_page', ['name' => $name, 'key' => $back]) : $this->redirectToRoute('admin_plugins');
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
@@ -17,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SeoController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
public function __construct(private \App\Plugin\PluginRuntime $plugins,
|
||||
private Connection $db,
|
||||
private SiteSettings $settings,
|
||||
private PostRepository $posts,
|
||||
@@ -74,6 +74,8 @@ final class SeoController extends AbstractController
|
||||
$entries[] = ['loc' => $origin.'/'.PostRepository::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug'], 'lastmod' => null];
|
||||
}
|
||||
|
||||
$entries = $this->plugins->applyFilters('sitemap.entries', $entries, $origin);
|
||||
|
||||
return $this->xml($this->renderView('seo/sitemap.xml.twig', ['entries' => $entries]));
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,9 @@ final class SystemController extends AbstractController
|
||||
@set_time_limit(0);
|
||||
if ($request->request->getBoolean('finish')) {
|
||||
$ergebnis = $this->archive->finish($this->installation);
|
||||
if ($ergebnis['laeuft'] ?? false) {
|
||||
return $this->back(ArchiveUpdate::LAEUFT_BEREITS);
|
||||
}
|
||||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||||
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||||
? 'Das Update ist fertig. Die Website ist wieder erreichbar.'
|
||||
@@ -271,6 +274,10 @@ final class SystemController extends AbstractController
|
||||
}
|
||||
@set_time_limit(0);
|
||||
$ergebnis = $this->archive->finish($this->installation, $this->pruefung);
|
||||
if ($ergebnis['laeuft'] ?? false) {
|
||||
// Der andere Abschluss schreibt den Stand fort; die Systemseite fragt ihn weiter ab.
|
||||
return new JsonResponse(['ok' => true, 'meldung' => ArchiveUpdate::LAEUFT_BEREITS, 'status' => $this->archive->status()]);
|
||||
}
|
||||
$this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]);
|
||||
}
|
||||
|
||||
@@ -311,6 +318,9 @@ final class SystemController extends AbstractController
|
||||
}
|
||||
@set_time_limit(0);
|
||||
$ergebnis = $this->archive->finish($this->installation);
|
||||
if ($ergebnis['laeuft'] ?? false) {
|
||||
return $this->back(ArchiveUpdate::LAEUFT_BEREITS);
|
||||
}
|
||||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||||
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||||
? sprintf('Update auf %s abgeschlossen. Die Website ist wieder erreichbar.', $offen['fassung'])
|
||||
|
||||
@@ -62,7 +62,7 @@ final class RemoteUpdateFinishSubscriber implements EventSubscriberInterface
|
||||
}
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit(0);
|
||||
$this->archive->finish($this->installation, $this->pruefung);
|
||||
$this->archive->finish($this->installation, $this->pruefung, true);
|
||||
} catch (\Throwable) {
|
||||
// Was schiefging, steht im Updateprotokoll; die Wartungsseite bleibt dann an.
|
||||
} finally {
|
||||
|
||||
@@ -23,6 +23,8 @@ namespace App\Ops;
|
||||
*/
|
||||
final class ArchiveUpdate
|
||||
{
|
||||
public const LAEUFT_BEREITS = 'Der Abschluss läuft bereits in einer anderen Anfrage. Diese Seite in einem Moment neu laden.';
|
||||
|
||||
/** Was aus dem Archiv übernommen wird. Alles andere im Projektstamm bleibt unberührt. */
|
||||
private const ERSETZEN = ['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION'];
|
||||
|
||||
@@ -363,7 +365,28 @@ final class ArchiveUpdate
|
||||
* gibt null zurück oder den Grund, warum sie nicht
|
||||
* läuft - dann wird zurückgeschaltet
|
||||
*/
|
||||
public function finish(Installation $installation, ?callable $pruefen = null): array
|
||||
public function finish(Installation $installation, ?callable $pruefen = null, bool $gesperrt = false): array
|
||||
{
|
||||
// Nur ein Abschluss zur selben Zeit. Die Zwischenseite ruft ihn von selbst auf und bietet
|
||||
// daneben einen Link an: Zwei Abschlüsse nebeneinander ließen den zweiten an der bereits
|
||||
// laufenden Migration scheitern - und der schaltete dann zurück, Code und Datenbank,
|
||||
// obwohl der erste gelungen war. $gesperrt: der Aufrufer hält die Sperre schon.
|
||||
$sperre = $gesperrt ? null : $this->updater->lock();
|
||||
if (!$gesperrt && null === $sperre) {
|
||||
return ['ok' => false, 'laeuft' => true, 'steps' => [['step' => 'Abschluss', 'ok' => true, 'detail' => self::LAEUFT_BEREITS]]];
|
||||
}
|
||||
try {
|
||||
return $this->abschliessen($installation, $pruefen);
|
||||
} finally {
|
||||
if (null !== $sperre) {
|
||||
flock($sperre, LOCK_UN);
|
||||
fclose($sperre);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param (callable(): ?string)|null $pruefen */
|
||||
private function abschliessen(Installation $installation, ?callable $pruefen): array
|
||||
{
|
||||
$schritte = [];
|
||||
$offen = $this->pending();
|
||||
|
||||
+14
-2
@@ -62,6 +62,7 @@ final class Scheduler
|
||||
private Connection $db,
|
||||
private iterable $tasks,
|
||||
private string $projectDir,
|
||||
private ?\App\Plugin\PluginRuntime $plugins = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -268,6 +269,14 @@ final class Scheduler
|
||||
* @return bool false, wenn die Tabelle (noch) nicht da ist - vor der Installation und
|
||||
* zwischen hochgeladenem Code und ausgefuehrter Migration
|
||||
*/
|
||||
/** Nach dem Ein- oder Ausschalten eines Plugins: Der Plan kennt dessen Aufgaben sofort. */
|
||||
public function pluginsChanged(): void
|
||||
{
|
||||
$this->registry = null;
|
||||
$this->sync();
|
||||
$this->refreshState();
|
||||
}
|
||||
|
||||
public function sync(?\DateTimeImmutable $now = null): bool
|
||||
{
|
||||
$now ??= self::now();
|
||||
@@ -282,7 +291,7 @@ final class Scheduler
|
||||
$this->db->insert('scheduled_task', [
|
||||
'name' => $name,
|
||||
'interval_seconds' => $task->interval(),
|
||||
'enabled' => true,
|
||||
'enabled' => 1,
|
||||
'next_run_at' => $now->modify('+120 seconds')->format('Y-m-d H:i:s'),
|
||||
'last_message' => '',
|
||||
'last_duration_ms' => 0,
|
||||
@@ -348,7 +357,7 @@ final class Scheduler
|
||||
|
||||
public function enable(string $name, bool $on): void
|
||||
{
|
||||
$this->db->executeStatement('UPDATE scheduled_task SET enabled = ? WHERE name = ?', [$on, $name]);
|
||||
$this->db->executeStatement('UPDATE scheduled_task SET enabled = ? WHERE name = ?', [(int) $on, $name]);
|
||||
$this->refreshState();
|
||||
}
|
||||
|
||||
@@ -396,6 +405,9 @@ final class Scheduler
|
||||
foreach ($this->tasks as $task) {
|
||||
$registry[$task->name()] = $task;
|
||||
}
|
||||
foreach ($this->plugins?->tasks() ?? [] as $task) {
|
||||
$registry[$task->name()] = $task;
|
||||
}
|
||||
ksort($registry);
|
||||
|
||||
return $this->registry = $registry;
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Plugin;
|
||||
|
||||
use App\Content\MediaLibrary;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* Was die plugin.php eines Plugins vom Kern bekommt (ADR 127).
|
||||
*
|
||||
* Eine Instanz je Plugin. Das Plugin meldet hier an, was es kann - Hooks, Aufgaben im Takt, Seiten,
|
||||
* Bildschirme der Verwaltung -, und bekommt seine Einstellungen, die Datenbank und die Mediathek.
|
||||
* Der Kern übernimmt die Anmeldungen erst, wenn die plugin.php ohne Fehler durchgelaufen ist.
|
||||
*
|
||||
* Plugin-Code läuft mit den vollen Rechten der Anwendung, wie bei WordPress. Das ist kein
|
||||
* Sandkasten und wird nicht so genannt: Ein Plugin installiert nur, wer dem Code traut.
|
||||
*/
|
||||
final class PluginApi
|
||||
{
|
||||
/** Größte Antwort, die fetch() annimmt. */
|
||||
private const MAX_BYTES = 16_000_000;
|
||||
|
||||
/** @var array{install: list<callable>, deactivate: list<callable>, actions: array<string, list<array{int, callable}>>, filters: array<string, list<array{int, callable}>>, tasks: array<string, array>, pages: list<array>, data: array<string, callable>, adminPages: array<string, array>, adminActions: array<string, array>} */
|
||||
public array $registered = ['install' => [], 'deactivate' => [], 'actions' => [], 'filters' => [], 'tasks' => [], 'pages' => [], 'data' => [], 'adminPages' => [], 'adminActions' => []];
|
||||
|
||||
public function __construct(
|
||||
private string $name,
|
||||
private string $directory,
|
||||
private PluginRegistry $plugins,
|
||||
private Connection $db,
|
||||
private MediaLibrary $media,
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/** Der Ordner des Plugins, ohne Schrägstrich am Ende. */
|
||||
public function directory(): string
|
||||
{
|
||||
return $this->directory;
|
||||
}
|
||||
|
||||
/** Die geprüften Einstellungen aus der plugin.json, jede vorhanden. */
|
||||
public function settings(): array
|
||||
{
|
||||
return $this->plugins->settingsOf($this->name);
|
||||
}
|
||||
|
||||
public function db(): Connection
|
||||
{
|
||||
return $this->db;
|
||||
}
|
||||
|
||||
/** Der Name einer eigenen Tabelle: plugin_<name>_<kurz>. Andere Tabellen gehören dem Kern. */
|
||||
public function table(string $short): string
|
||||
{
|
||||
if (1 !== preg_match('/^[a-z][a-z0-9_]{0,30}$/D', $short)) {
|
||||
throw new \InvalidArgumentException('Ein Tabellenname besteht aus Kleinbuchstaben, Ziffern und Unterstrichen.');
|
||||
}
|
||||
|
||||
return 'plugin_'.$this->name.'_'.$short;
|
||||
}
|
||||
|
||||
public function media(): MediaLibrary
|
||||
{
|
||||
return $this->media;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Lebenszyklus
|
||||
|
||||
/**
|
||||
* Läuft beim Einschalten und wenn sich die Fassung des Plugins geändert hat - muss also
|
||||
* wiederholbar sein (CREATE TABLE IF NOT EXISTS). Wirft es, bleibt das Plugin aus.
|
||||
*/
|
||||
public function onInstall(callable $callback): void
|
||||
{
|
||||
$this->registered['install'][] = $callback;
|
||||
}
|
||||
|
||||
/** Läuft beim Ausschalten. Eigene Tabellen bleiben stehen, wie die Bausteine in den Seiten. */
|
||||
public function onDeactivate(callable $callback): void
|
||||
{
|
||||
$this->registered['deactivate'][] = $callback;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Hooks
|
||||
|
||||
/** Reagiert auf ein Ereignis des Kerns oder eines anderen Plugins. Kleinere Priorität zuerst. */
|
||||
public function addAction(string $hook, callable $callback, int $priority = 10): void
|
||||
{
|
||||
$this->registered['actions'][$hook][] = [$priority, $callback];
|
||||
}
|
||||
|
||||
/** Verändert einen Wert: Der Callback bekommt ihn als erstes Argument und gibt ihn zurück. */
|
||||
public function addFilter(string $hook, callable $callback, int $priority = 10): void
|
||||
{
|
||||
$this->registered['filters'][$hook][] = [$priority, $callback];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Anmeldungen
|
||||
|
||||
/**
|
||||
* Eine Aufgabe im Takt. Sie steht unter „Aufgaben“ im Plan des Kerns; Fehler soll sie werfen,
|
||||
* ihre Rückgabe ist der Bericht. Die Regeln stehen in App\Ops\Tasks\Task.
|
||||
*
|
||||
* @param callable(): string $callback
|
||||
*/
|
||||
public function addTask(string $key, string $label, string $explanation, int $interval, callable $callback): void
|
||||
{
|
||||
$this->key($key);
|
||||
if ($interval < 60) {
|
||||
throw new \InvalidArgumentException('Eine Aufgabe läuft höchstens jede Minute.');
|
||||
}
|
||||
$this->registered['tasks'][$key] = ['key' => $key, 'label' => $label, 'explanation' => $explanation, 'interval' => $interval, 'callback' => $callback];
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine öffentliche Seite im Rahmen der Website, zum Beispiel `/objekte/{slug}`.
|
||||
*
|
||||
* Der Callback bekommt die Platzhalter und gibt null zurück (dann 404) oder ein Array mit
|
||||
* `title`, `description`, `blocks` und optional `seoTitle`, `robots`, `status`, `image`.
|
||||
* Eine veröffentlichte Seite mit derselben Adresse geht vor.
|
||||
*
|
||||
* @param callable(array<string, string>): ?array $callback
|
||||
*/
|
||||
public function addPage(string $pattern, callable $callback): void
|
||||
{
|
||||
if (1 !== preg_match('~^(?:/(?:[a-z0-9]+(?:-[a-z0-9]+)*|\{[a-z]+\})){1,4}$~D', $pattern)) {
|
||||
throw new \InvalidArgumentException(sprintf('Die Adresse „%s“ hat nicht die Form /name/{platzhalter}.', $pattern));
|
||||
}
|
||||
if (1 === preg_match('~^/(admin|media|assets|plugin-assets|formular|kategorie|schlagwort|beitraege|einwilligung)(/|$)~', $pattern)) {
|
||||
throw new \InvalidArgumentException(sprintf('Die Adresse „%s“ gehört dem Kern.', $pattern));
|
||||
}
|
||||
$regex = '~^'.preg_replace('~\\\\\{([a-z]+)\\\\\}~', '(?P<$1>[a-z0-9]+(?:-[a-z0-9]+)*)', preg_quote($pattern, '~')).'$~D';
|
||||
$this->registered['pages'][] = ['pattern' => $pattern, 'regex' => $regex, 'callback' => $callback];
|
||||
}
|
||||
|
||||
/**
|
||||
* Daten für die Twig-Vorlagen: `kant_plugin_data('name', 'schluessel', {…})`.
|
||||
*
|
||||
* @param callable(array): mixed $callback
|
||||
*/
|
||||
public function addData(string $key, callable $callback): void
|
||||
{
|
||||
$this->key($key);
|
||||
$this->registered['data'][$key] = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Bildschirm der Verwaltung, nur für Administratoren. Die Vorlage liegt im Pluginordner.
|
||||
*
|
||||
* @param callable(): array $callback liefert die Werte für die Vorlage
|
||||
*/
|
||||
public function addAdminPage(string $key, string $title, string $template, callable $callback): void
|
||||
{
|
||||
$this->key($key);
|
||||
if (1 !== preg_match('~^[a-zA-Z0-9_/-]+\.html\.twig$~D', $template) || !is_file($this->directory.'/'.$template)) {
|
||||
throw new \InvalidArgumentException(sprintf('Die Vorlage „%s“ fehlt im Pluginordner.', $template));
|
||||
}
|
||||
$this->registered['adminPages'][$key] = ['key' => $key, 'title' => $title, 'template' => '@plugins/'.$this->name.'/'.$template, 'callback' => $callback];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Knopf auf den Bildschirmen des Plugins. Entweder startet er eine angemeldete Aufgabe
|
||||
* (`$handler` ist ihr Schlüssel, die Sperre des Aufgabenplans gilt) oder ruft einen Callback,
|
||||
* dessen Rückgabe als Meldung erscheint.
|
||||
*
|
||||
* @param string|callable(): string $handler
|
||||
*/
|
||||
public function addAdminAction(string $key, string $label, string|callable $handler): void
|
||||
{
|
||||
$this->key($key);
|
||||
$this->registered['adminActions'][$key] = ['key' => $key, 'label' => $label, 'handler' => $handler];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Werkzeuge
|
||||
|
||||
/**
|
||||
* Ruft eine HTTPS-Adresse ab. Wirft bei einem Netzfehler; ein HTTP-Fehler ist eine Antwort.
|
||||
*
|
||||
* @param array{userAgent?: string, timeout?: int, maxBytes?: int} $options
|
||||
*
|
||||
* @return array{status: int, body: string, contentType: string}
|
||||
*/
|
||||
public function fetch(string $url, array $options = []): array
|
||||
{
|
||||
if (!str_starts_with($url, 'https://') || false === filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
throw new \InvalidArgumentException('Abgerufen werden nur HTTPS-Adressen.');
|
||||
}
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new \RuntimeException('Auf diesem Hosting fehlt die PHP-Erweiterung cURL.');
|
||||
}
|
||||
$limit = min(self::MAX_BYTES, $options['maxBytes'] ?? self::MAX_BYTES);
|
||||
$body = '';
|
||||
$handle = curl_init($url);
|
||||
curl_setopt_array($handle, [
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_CONNECTTIMEOUT => 8,
|
||||
CURLOPT_TIMEOUT => max(1, min(60, $options['timeout'] ?? 20)),
|
||||
CURLOPT_USERAGENT => $options['userAgent'] ?? 'KantSites-Plugin/'.$this->name,
|
||||
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$body, $limit): int {
|
||||
$body .= $chunk;
|
||||
|
||||
return strlen($body) > $limit ? 0 : strlen($chunk);
|
||||
},
|
||||
]);
|
||||
if (false === curl_exec($handle)) {
|
||||
throw new \RuntimeException('Abruf gescheitert: '.curl_error($handle));
|
||||
}
|
||||
|
||||
return ['status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE), 'body' => $body, 'contentType' => (string) curl_getinfo($handle, CURLINFO_CONTENT_TYPE)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt Bilddaten in der Mediathek ab und gibt den Eintrag zurück.
|
||||
*
|
||||
* @throws \InvalidArgumentException wenn die Daten kein brauchbares Bild sind
|
||||
*/
|
||||
public function storeImage(string $bytes, string $title, string $alt = ''): array
|
||||
{
|
||||
$temp = tempnam(sys_get_temp_dir(), 'kant-plugin-');
|
||||
if (false === $temp || false === file_put_contents($temp, $bytes)) {
|
||||
throw new \RuntimeException('Die Bilddaten ließen sich nicht zwischenspeichern.');
|
||||
}
|
||||
try {
|
||||
$item = $this->media->upload(new UploadedFile($temp, 'bild.jpg', null, null, true));
|
||||
$this->media->update($item['id'], ['title' => mb_substr('' !== trim($title) ? $title : 'Bild', 0, 40), 'alt' => mb_substr($alt, 0, 150), 'archived' => false, 'version' => $item['version']]);
|
||||
|
||||
return $this->media->find($item['id']) ?? $item;
|
||||
} finally {
|
||||
@unlink($temp);
|
||||
}
|
||||
}
|
||||
|
||||
private function key(string $key): void
|
||||
{
|
||||
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,40}$/D', $key)) {
|
||||
throw new \InvalidArgumentException(sprintf('Der Schlüssel „%s“ besteht aus Kleinbuchstaben, Ziffern und Bindestrichen.', $key));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ use Doctrine\DBAL\Connection;
|
||||
*
|
||||
* Seit dem zweiten Teil von Arbeitspaket H (ADR 103) kann ein Plugin außerdem: Bausteine mit
|
||||
* Kindern (Container), Einstellungen für den Betreiber, eigene öffentliche Adressen und
|
||||
* Seitenvorlagen für den Editor. Alles davon ist Manifest plus Twig - PHP-Code bringt ein
|
||||
* Plugin nicht mit, und deshalb gibt es auch keine Hooks in Speichern oder Veröffentlichen.
|
||||
* Seitenvorlagen für den Editor. Seit ADR 127 darf es zusätzlich eine plugin.php mitbringen
|
||||
* ("main" im Manifest); was die anmeldet, führt PluginRuntime aus.
|
||||
*
|
||||
* Eingeschaltet wird ein Plugin in der Verwaltung; der Schalter liegt in der Datenbank. Ein
|
||||
* ausgeschaltetes oder fehlendes Plugin lässt seine Bausteine unangetastet im Dokument (ADR 011):
|
||||
@@ -144,6 +144,12 @@ final class PluginRegistry
|
||||
$this->write($name, ['settings' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)]);
|
||||
}
|
||||
|
||||
/** Die Fassung, für die die plugin.php zuletzt eingerichtet wurde. */
|
||||
public function markInstalled(string $name): void
|
||||
{
|
||||
$this->write($name, ['version' => $this->all()[$name]['version']]);
|
||||
}
|
||||
|
||||
/** Die Einstellungen eines Plugins mit Vorgaben - leer, wenn es das Plugin nicht gibt. */
|
||||
public function settingsOf(string $name): array
|
||||
{
|
||||
@@ -154,8 +160,10 @@ final class PluginRegistry
|
||||
private function write(string $name, array $fields): void
|
||||
{
|
||||
$existing = $this->rows()[$name] ?? null;
|
||||
// Wahrheitswerte als Zahl: false ginge sonst als leere Zeichenkette an die Datenbank.
|
||||
$fields = array_map(static fn (mixed $value): mixed => \is_bool($value) ? (int) $value : $value, $fields);
|
||||
if (null === $existing) {
|
||||
$this->db->insert('plugin', ['name' => $name, 'active' => false, 'version' => '', 'activated_at' => null, 'settings' => '{}', ...$fields]);
|
||||
$this->db->insert('plugin', ['name' => $name, 'active' => 0, 'version' => '', 'activated_at' => null, 'settings' => '{}', ...$fields]);
|
||||
} else {
|
||||
$this->db->update('plugin', $fields, ['name' => $name]);
|
||||
}
|
||||
@@ -423,11 +431,15 @@ final class PluginRegistry
|
||||
}
|
||||
$routes = $this->routes($name, $data['routes'] ?? []);
|
||||
$presets = $this->presets($name, $data['presets'] ?? []);
|
||||
if ([] === $blocks && [] === $routes && [] === $presets) {
|
||||
throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage.');
|
||||
$main = $data['main'] ?? null;
|
||||
if (null !== $main && (!is_string($main) || 1 !== preg_match('~^[a-zA-Z0-9_-]+\.php$~D', $main) || !is_file($this->directory().'/'.$name.'/'.$main))) {
|
||||
throw new \RuntimeException(sprintf('Die Datei „%s“ aus "main" fehlt im Pluginordner.', is_string($main) ? $main : ''));
|
||||
}
|
||||
if ([] === $blocks && [] === $routes && [] === $presets && null === $main) {
|
||||
throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse, eine Vorlage oder eine plugin.php.');
|
||||
}
|
||||
|
||||
return ['name' => $name, 'title' => $title, 'description' => mb_substr(trim((string) ($data['description'] ?? '')), 0, 300), 'version' => $version, 'requires' => $requires, 'blocks' => $blocks, 'settings' => $settings, 'routes' => $routes, 'presets' => $presets];
|
||||
return ['main' => $main, 'name' => $name, 'title' => $title, 'description' => mb_substr(trim((string) ($data['description'] ?? '')), 0, 300), 'version' => $version, 'requires' => $requires, 'blocks' => $blocks, 'settings' => $settings, 'routes' => $routes, 'presets' => $presets];
|
||||
}
|
||||
|
||||
private function blockDefinition(string $plugin, string $key, array $block, string $version): array
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace App\Plugin;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Ops\Tasks\Task;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Lädt die plugin.php der aktiven Plugins und führt aus, was sie angemeldet haben (ADR 127).
|
||||
*
|
||||
* Ein Plugin, das beim Laden oder in einem Hook eine Ausnahme wirft, darf die Website nicht
|
||||
* mitreißen: Es fällt für diese Anfrage aus, der Grund steht auf dem Bildschirm „Plugins“. Einen
|
||||
* echten PHP-Abbruch fängt auch das nicht ab - dann hilft, den Ordner umzubenennen.
|
||||
*/
|
||||
final class PluginRuntime
|
||||
{
|
||||
/** @var array<string, PluginApi>|null */
|
||||
private ?array $apis = null;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private array $errors = [];
|
||||
|
||||
public function __construct(private PluginRegistry $plugins, private Connection $db, private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
/** Nach dem Ein- oder Ausschalten: beim nächsten Zugriff neu laden. */
|
||||
public function reset(): void
|
||||
{
|
||||
$this->apis = null;
|
||||
$this->errors = [];
|
||||
}
|
||||
|
||||
/** @return array<string, string> Plugin => Grund, aus dem es gerade nicht läuft */
|
||||
public function errors(): array
|
||||
{
|
||||
$this->apis();
|
||||
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schaltet ein und richtet ein. Scheitert das Einrichten, bleibt das Plugin aus.
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function activate(string $name): void
|
||||
{
|
||||
$this->plugins->activate($name);
|
||||
$this->reset();
|
||||
if (isset($this->errors()[$name])) {
|
||||
$reason = $this->errors()[$name];
|
||||
$this->plugins->deactivate($name);
|
||||
$this->reset();
|
||||
throw new \InvalidArgumentException('Das Plugin lässt sich nicht laden: '.$reason);
|
||||
}
|
||||
try {
|
||||
$this->install($this->apis()[$name] ?? null);
|
||||
} catch (\Throwable $e) {
|
||||
$this->plugins->deactivate($name);
|
||||
$this->reset();
|
||||
throw new \InvalidArgumentException('Das Plugin ließ sich nicht einrichten: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws \InvalidArgumentException */
|
||||
public function deactivate(string $name): void
|
||||
{
|
||||
foreach ($this->apis()[$name]->registered['deactivate'] ?? [] as $callback) {
|
||||
try {
|
||||
$callback();
|
||||
} catch (\Throwable $e) {
|
||||
// Ausschalten muss immer gehen, gerade bei einem Plugin, das Fehler macht.
|
||||
$this->errors[$name] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
$this->plugins->deactivate($name);
|
||||
$this->reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt ein Ereignis aus. Der Fehler eines Plugins hält die übrigen nicht auf.
|
||||
*/
|
||||
public function doAction(string $hook, mixed ...$arguments): void
|
||||
{
|
||||
foreach ($this->hooks('actions', $hook) as [$name, $callback]) {
|
||||
try {
|
||||
$callback(...$arguments);
|
||||
} catch (\Throwable $e) {
|
||||
$this->errors[$name] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reicht einen Wert durch die Filter. Wirft einer, gilt der Wert von davor.
|
||||
*/
|
||||
public function applyFilters(string $hook, mixed $value, mixed ...$arguments): mixed
|
||||
{
|
||||
foreach ($this->hooks('filters', $hook) as [$name, $callback]) {
|
||||
try {
|
||||
$value = $callback($value, ...$arguments);
|
||||
} catch (\Throwable $e) {
|
||||
$this->errors[$name] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** @return list<Task> */
|
||||
public function tasks(): array
|
||||
{
|
||||
$tasks = [];
|
||||
foreach ($this->apis() as $name => $api) {
|
||||
foreach ($api->registered['tasks'] as $task) {
|
||||
$tasks[] = new PluginTask(self::taskName($name, $task['key']), $task['label'], $task['explanation'], $task['interval'], $task['callback']);
|
||||
}
|
||||
}
|
||||
|
||||
return $tasks;
|
||||
}
|
||||
|
||||
public static function taskName(string $plugin, string $key): string
|
||||
{
|
||||
return 'plugin-'.$plugin.'-'.$key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Seite eines Plugins zu dieser Adresse, in der Form einer gespeicherten Seite - oder null.
|
||||
*/
|
||||
public function page(string $path): ?array
|
||||
{
|
||||
foreach ($this->apis() as $name => $api) {
|
||||
foreach ($api->registered['pages'] as $page) {
|
||||
if (1 !== preg_match($page['regex'], $path, $match)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$result = $page['callback'](array_filter($match, 'is_string', ARRAY_FILTER_USE_KEY));
|
||||
} catch (\Throwable $e) {
|
||||
$this->errors[$name] = $e->getMessage();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($result) ? $this->pageRecord($name, $path, $result) : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function data(string $plugin, string $key, array $arguments = []): mixed
|
||||
{
|
||||
$callback = $this->apis()[$plugin]->registered['data'][$key] ?? null;
|
||||
if (null === $callback) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return $callback($arguments);
|
||||
} catch (\Throwable $e) {
|
||||
$this->errors[$plugin] = $e->getMessage();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{pages: array<string, array>, actions: array<string, array>} */
|
||||
public function admin(string $plugin): array
|
||||
{
|
||||
$api = $this->apis()[$plugin] ?? null;
|
||||
|
||||
return ['pages' => $api?->registered['adminPages'] ?? [], 'actions' => $api?->registered['adminActions'] ?? []];
|
||||
}
|
||||
|
||||
/** @return array<string, PluginApi> nur Plugins, deren plugin.php fehlerfrei geladen wurde */
|
||||
private function apis(): array
|
||||
{
|
||||
if (null !== $this->apis) {
|
||||
return $this->apis;
|
||||
}
|
||||
$this->apis = [];
|
||||
foreach ($this->plugins->all() as $name => $plugin) {
|
||||
if (!$plugin['active'] || null !== $plugin['error'] || null === ($plugin['main'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
$directory = $this->plugins->directory().'/'.$name;
|
||||
$api = new PluginApi($name, $directory, $this->plugins, $this->db, $this->media);
|
||||
try {
|
||||
$boot = self::load($directory.'/'.$plugin['main']);
|
||||
if (!is_callable($boot)) {
|
||||
throw new \RuntimeException($plugin['main'].' gibt keine Funktion zurück.');
|
||||
}
|
||||
$boot($api);
|
||||
// Eine neue Fassung im Ordner: einrichten, bevor etwas sie benutzt.
|
||||
if ($plugin['activatedVersion'] !== $plugin['version']) {
|
||||
$this->install($api);
|
||||
$this->plugins->markInstalled($name);
|
||||
}
|
||||
$this->apis[$name] = $api;
|
||||
} catch (\Throwable $e) {
|
||||
$this->errors[$name] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->apis;
|
||||
}
|
||||
|
||||
/** In einer eigenen statischen Funktion, damit die Datei nichts von hier sieht. */
|
||||
private static function load(string $file): mixed
|
||||
{
|
||||
return require $file;
|
||||
}
|
||||
|
||||
private function install(?PluginApi $api): void
|
||||
{
|
||||
foreach ($api?->registered['install'] ?? [] as $callback) {
|
||||
$callback();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<array{string, callable}> nach Priorität, bei Gleichstand in Reihenfolge der Plugins */
|
||||
private function hooks(string $kind, string $hook): array
|
||||
{
|
||||
$found = [];
|
||||
foreach ($this->apis() as $name => $api) {
|
||||
foreach ($api->registered[$kind][$hook] ?? [] as [$priority, $callback]) {
|
||||
$found[] = [$priority, $name, $callback];
|
||||
}
|
||||
}
|
||||
usort($found, static fn (array $a, array $b): int => $a[0] <=> $b[0]);
|
||||
|
||||
return array_map(static fn (array $entry): array => [$entry[1], $entry[2]], $found);
|
||||
}
|
||||
|
||||
private function pageRecord(string $plugin, string $path, array $result): array
|
||||
{
|
||||
return [
|
||||
'id' => 'plugin-'.$plugin, 'path' => ltrim($path, '/'), 'type' => 'page', 'locale' => 'de',
|
||||
'seo_title' => (string) ($result['seoTitle'] ?? ''),
|
||||
'robots' => 'noindex' === ($result['robots'] ?? 'index') ? 'noindex' : 'index',
|
||||
'status' => (int) ($result['status'] ?? 200),
|
||||
'og_media_id' => $result['image'] ?? null,
|
||||
'header_area' => null, 'footer_area' => null,
|
||||
'document' => [
|
||||
'schemaVersion' => DocumentValidator::SCHEMA_VERSION,
|
||||
'title' => (string) ($result['title'] ?? ''),
|
||||
'description' => (string) ($result['description'] ?? ''),
|
||||
'blocks' => $result['blocks'] ?? [],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Plugin;
|
||||
|
||||
use App\Ops\Tasks\Task;
|
||||
|
||||
/** Eine Aufgabe, die ein Plugin angemeldet hat, im Vertrag des Aufgabenplans. */
|
||||
final class PluginTask implements Task
|
||||
{
|
||||
/** @param callable(): string $callback */
|
||||
public function __construct(
|
||||
private string $name,
|
||||
private string $label,
|
||||
private string $explanation,
|
||||
private int $interval,
|
||||
private $callback,
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return $this->explanation;
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return $this->interval;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
return (string) ($this->callback)();
|
||||
}
|
||||
}
|
||||
@@ -81,8 +81,8 @@ final class LanguageExtension extends AbstractExtension
|
||||
'Beiträge in der Kategorie „%s“.' => 'Posts in the category “%s”.',
|
||||
'Beiträge zum Schlagwort „%s“.' => 'Posts tagged “%s”.',
|
||||
'von' => 'by',
|
||||
'← Neuere' => '← Newer',
|
||||
'Ältere →' => 'Older →',
|
||||
'Neuere' => 'Newer',
|
||||
'Ältere' => 'Older',
|
||||
'Seite %d von %d' => 'Page %d of %d',
|
||||
'Externe Inhalte und Einwilligung' => 'External content and consent',
|
||||
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'This website only loads certain content once you agree. Without consent the page is shown in full; only embedded content from other providers stays empty.',
|
||||
@@ -173,8 +173,8 @@ final class LanguageExtension extends AbstractExtension
|
||||
'Beiträge in der Kategorie „%s“.' => '„%s“ kategorisindeki yazılar.',
|
||||
'Beiträge zum Schlagwort „%s“.' => '„%s“ etiketli yazılar.',
|
||||
'von' => 'yazan:',
|
||||
'← Neuere' => '← Daha yeni',
|
||||
'Ältere →' => 'Daha eski →',
|
||||
'Neuere' => 'Daha yeni',
|
||||
'Ältere' => 'Daha eski',
|
||||
'Seite %d von %d' => 'Sayfa %d / %d',
|
||||
'Externe Inhalte und Einwilligung' => 'Harici içerikler ve onay',
|
||||
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'Bu web sitesi bazı içerikleri ancak onayınızdan sonra yükler. Onay vermeseniz de sayfa eksiksiz görüntülenir; yalnızca diğer sağlayıcıların yerleştirilmiş içerikleri boş kalır.',
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Twig;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
use Twig\TwigFunction;
|
||||
@@ -15,7 +16,7 @@ use Twig\TwigFunction;
|
||||
*/
|
||||
final class PluginExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(private PluginRegistry $plugins, private MediaLibrary $media, FilesystemLoader $loader)
|
||||
public function __construct(private PluginRegistry $plugins, private MediaLibrary $media, FilesystemLoader $loader, private PluginRuntime $runtime)
|
||||
{
|
||||
// Der Namensraum @plugins zeigt auf den Pluginordner - aber nur, wenn es ihn gibt. Ein
|
||||
// fester Pfad in twig.yaml liesse eine Installation ohne plugins/ mit einem Ladefehler
|
||||
@@ -33,6 +34,7 @@ final class PluginExtension extends AbstractExtension
|
||||
new TwigFunction('kant_plugin_props', $this->props(...)),
|
||||
new TwigFunction('kant_plugin_media', $this->mediaItem(...)),
|
||||
new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)),
|
||||
new TwigFunction('kant_plugin_data', $this->runtime->data(...)),
|
||||
new TwigFunction('kant_media', $this->adminMedia(...)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'plugins' %}
|
||||
{% block title %}{{ page.title }}{% endblock %}
|
||||
{% block heading %}{{ page.title }}{% endblock %}
|
||||
{% block lead %}{{ plugin.title }} · <a href="{{ path('admin_plugins') }}">Zurück zu den Plugins</a>{% endblock %}
|
||||
{% block content %}
|
||||
{% if actions %}
|
||||
<div class="favicon-actions">
|
||||
{% for action in actions %}
|
||||
<form method="post" action="{{ path('admin_plugin_action', {name: plugin.name, key: action.key}) }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
|
||||
<input type="hidden" name="zurueck" value="{{ page.key }}">
|
||||
<button class="secondary" type="submit">{{ action.label }}</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include page.template with data only %}
|
||||
{% endblock %}
|
||||
@@ -56,6 +56,9 @@
|
||||
<dd>{% if plugin.active %}<a href="{{ route.path }}">{{ route.path }}</a>{% else %}<code>{{ route.path }}</code>{% endif %} · {{ route.type }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% if plugin.main %}<p class="muted">Führt eigenen PHP-Code mit den vollen Rechten der Website aus. Nur einschalten, wenn du der Quelle vertraust.</p>{% endif %}
|
||||
{% if runtimeErrors[plugin.name] is defined %}<p class="field-error">Fehler im Plugin: {{ runtimeErrors[plugin.name] }}</p>{% endif %}
|
||||
{% for page in admin[plugin.name].pages %}<p><a href="{{ path('admin_plugin_page', {name: plugin.name, key: page.key}) }}">{{ page.title }}</a></p>{% endfor %}
|
||||
{% if plugin.active and plugin.settings is not empty %}
|
||||
<form method="post" action="{{ path('admin_plugin_settings', {name: plugin.name}) }}" class="plugin-settings">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
{% elseif type == 'quote' %}
|
||||
<figure id="{{ block.id }}" class="block-quote{{ designClasses }}">
|
||||
{% if props.rating|default(0) > 0 %}
|
||||
<p class="quote-rating"><span class="visually-hidden">Bewertung: {{ props.rating }} von 5</span><span aria-hidden="true">{% for i in 1..5 %}{{ i <= props.rating ? '★' : '☆' }}{% endfor %}</span></p>
|
||||
<p class="quote-rating"><span class="visually-hidden">Bewertung: {{ props.rating }} von 5</span><span aria-hidden="true">{% for i in 1..5 %}<svg class="{{ i <= props.rating ? 'quote-star-voll' : 'quote-star' }}" viewBox="0 0 24 24" focusable="false"><path d="m12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z"/></svg>{% endfor %}</span></p>
|
||||
{% endif %}
|
||||
<blockquote><p>{{ props.text }}</p></blockquote>
|
||||
<figcaption>{{ props.author }}{% if props.role|default('') %} <span>{{ props.role }}</span>{% endif %}</figcaption>
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
|
||||
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||
house: 'M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1Z',
|
||||
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
|
||||
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
|
||||
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
{% if pages|default(1) > 1 %}
|
||||
{# Dieselbe Blätterleiste wie im Archiv: eine Liste, die mehr hat als sie zeigt, sagt es. #}
|
||||
<nav class="post-pager" aria-label="{{ 'Weitere Beiträge'|kant_t }}">
|
||||
{% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">{{ '← Neuere'|kant_t }}</a>{% endif %}
|
||||
{% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">{{ 'Neuere'|kant_t }}</a>{% endif %}
|
||||
<span>{{ 'Seite %d von %d'|kant_t|format(page, pages) }}{% if total %} · {{ total }} {{ 'Beiträge'|kant_t }}{% endif %}</span>
|
||||
{% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">{{ 'Ältere →'|kant_t }}</a>{% endif %}
|
||||
{% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">{{ 'Ältere'|kant_t }}</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
@@ -125,7 +125,7 @@
|
||||
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. Der nav-Rahmen
|
||||
gibt ihm eine Landmarke: ein Verweis außerhalb jeder Landmarke ist für Vorlesesoftware
|
||||
Inhalt ohne Ort, und axe meldet ihn als Verstoß gegen „region“. #}
|
||||
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><span aria-hidden="true">↑</span></a></nav>
|
||||
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V4.5m-6.5 6.5L12 4.5l6.5 6.5"/></svg></a></nav>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -311,6 +311,31 @@ final class ArchiveUpdateTest extends TestCase
|
||||
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
|
||||
}
|
||||
|
||||
public function testASecondFinishWhileOneRunsDoesNotSwitchBack(): void
|
||||
{
|
||||
// Meta-Refresh und der Link „Weiter, falls nichts passiert“ können den Abschluss zweimal
|
||||
// anstoßen. Der zweite darf nichts tun - vorher scheiterte er an der laufenden Migration
|
||||
// und schaltete ein gelungenes Update zurück.
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
|
||||
self::assertTrue($this->updater()->run()['ok']);
|
||||
$sperre = (new Updater($this->root, 'sqlite:///'.$this->root.'/var/cms.db', new CommandRunner(), $this->db))->lock();
|
||||
self::assertNotNull($sperre);
|
||||
|
||||
$zweiter = $this->updater()->finish($this->installation(), static fn (): string => 'würde zurückschalten');
|
||||
|
||||
self::assertTrue($zweiter['laeuft'] ?? false);
|
||||
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')), 'Der Code der neuen Fassung bleibt.');
|
||||
self::assertNotNull($this->updater()->pending(), 'Der offene Abschluss gehört weiter dem ersten Lauf.');
|
||||
self::assertSame('migrieren', $this->updater()->status()['phase']);
|
||||
|
||||
flock($sperre, LOCK_UN);
|
||||
fclose($sperre);
|
||||
// Wer die Sperre schon hält (der Abschluss aus der Ferne), läuft durch.
|
||||
$fertig = $this->updater()->finish($this->installation());
|
||||
self::assertTrue($fertig['ok'], $this->explain($fertig));
|
||||
self::assertNull($this->updater()->pending());
|
||||
}
|
||||
|
||||
public function testANewVersionThatDoesNotRunIsSwitchedBackCompletely(): void
|
||||
{
|
||||
// Wie bei reiseSoft: Läuft die neue Fassung nach den Migrationen nicht, kommt die alte
|
||||
|
||||
@@ -163,6 +163,39 @@ final class BacklogTest extends TestCase
|
||||
self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent());
|
||||
}
|
||||
|
||||
public function testAFormInTheFooterCanBeSentFromAnArchive(): void
|
||||
{
|
||||
(new FormRepository($this->db))->save('kontakt', 'Kontakt', [
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Einverstanden'],
|
||||
], ['recipient' => 'info@example.org'], 0);
|
||||
$footer = $this->partials->findByKind('footer');
|
||||
$doc = $footer['document'];
|
||||
$doc['schemaVersion'] = 23;
|
||||
$doc['blocks'][] = ['id' => 'fuss-formular', 'type' => 'form', 'props' => ['form' => 'kontakt']];
|
||||
$this->partials->save($footer['id'], $doc, $footer['version']);
|
||||
$this->publishHome(DemoContent::starter('Start'));
|
||||
$this->post('erster', 'Erster Beitrag', '2026-01-01 10:00', ['Neues']);
|
||||
self::assertStringContainsString('name="page" value="beitraege"', $this->request('/beitraege')->getContent());
|
||||
|
||||
// Ein Archiv ist keine gespeicherte Seite. Vorher endete das Absenden hier in einer 404,
|
||||
// bevor irgendetwas gespeichert oder verschickt war.
|
||||
$body = fn (string $page, string $email): array => ['page' => $page, 'block' => 'fuss-formular', 'issued' => (string) (time() - 10), 'issuedSignature' => FormRepository::stamp('kontakt', time() - 10, 'test-only'), 'email' => $email, 'datenschutz' => 'ja'];
|
||||
$fehler = $this->request('/formular/kontakt', 'POST', $body('beitraege', ''));
|
||||
self::assertSame(422, $fehler->getStatusCode());
|
||||
self::assertStringContainsString('Dieses Feld wird benötigt.', $fehler->getContent());
|
||||
self::assertStringContainsString('Erster Beitrag', $fehler->getContent(), 'Die Meldung steht im Archiv, nicht auf einer Fehlerseite.');
|
||||
|
||||
foreach (['beitraege', 'kategorie/neues'] as $n => $archiv) {
|
||||
$antwort = $this->request('/formular/kontakt', 'POST', $body($archiv, 'kunde'.$n.'@example.org'));
|
||||
self::assertSame(303, $antwort->getStatusCode());
|
||||
self::assertSame('/'.$archiv.'?gesendet=kontakt#fuss-formular', $antwort->headers->get('Location'));
|
||||
self::assertSame($n + 1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'), 'Die Anfrage ist gespeichert.');
|
||||
}
|
||||
self::assertSame(200, $this->request('/beitraege?gesendet=kontakt')->getStatusCode());
|
||||
self::assertSame(404, $this->request('/formular/kontakt', 'POST', $body('gibt-es-nicht', 'x@example.org'))->getStatusCode(), 'Eine unbekannte Seite bleibt eine unbekannte Seite.');
|
||||
}
|
||||
|
||||
public function testASectionLinkedTwiceRendersWithoutDuplicateIds(): void
|
||||
{
|
||||
$sektion = DemoContent::starter('Band');
|
||||
|
||||
@@ -931,6 +931,18 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('href="https://cms.test/beitraege"', $html);
|
||||
}
|
||||
|
||||
public function testACategoryNameIsPrintedAsTextInItsArchiveHeading(): void
|
||||
{
|
||||
// Die Überschrift des Archivs läuft durch den Baustein „Überschrift“, der seinen Text als
|
||||
// Markup druckt. Ein Kategoriename ist aber ungefilterter Text eines Redakteurs.
|
||||
$beitrag = $this->publishedPost('markup', 'Markup', '2026-02-01', ['Bau & <img src="https://example.org/x.png"> Technik']);
|
||||
$href = $this->posts->termsFor($beitrag)['category'][0]['href'];
|
||||
|
||||
$html = $this->request($href)->getContent();
|
||||
self::assertStringNotContainsString('<img src="https://example.org/x.png">', $html);
|
||||
self::assertStringContainsString('Bau & <img src="https://example.org/x.png"> Technik</h1>', $html);
|
||||
}
|
||||
|
||||
public function testCategoriesAndTagsGroupPostsAndGetTheirOwnArchives(): void
|
||||
{
|
||||
$projekt = $this->publishedPost('kanttresor', 'KantTresor', '2026-02-01', ['Projekte'], ['WordPress', 'DSGVO']);
|
||||
|
||||
@@ -382,9 +382,23 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$client->request('POST', '/admin/pages/'.$entwurf.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($entwurf), 'slug' => 'neuer-name']);
|
||||
self::assertSame('neuer-name', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$entwurf]));
|
||||
|
||||
// Liegt die veröffentlichte Seite im Papierkorb, holt er sie nicht zurück: sie wäre sofort
|
||||
// wieder online. Seinen Entwurf dagegen schon.
|
||||
$pages->trashPage($live, $version($live));
|
||||
$pages->trashPage($entwurf, $version($entwurf));
|
||||
foreach ([$live, $entwurf] as $id) {
|
||||
$client->request('POST', '/admin/pages/'.$id.'/restore', ['_csrf_token' => $token]);
|
||||
self::assertResponseRedirects();
|
||||
}
|
||||
self::assertNotNull($this->db->fetchOne('SELECT deleted_at FROM page WHERE id = ?', [$live]), 'Ein Redakteur stellt keine veröffentlichte Seite wieder online.');
|
||||
self::assertNull($this->db->fetchOne('SELECT deleted_at FROM page WHERE id = ?', [$entwurf]));
|
||||
|
||||
// Wer veröffentlichen darf, darf auch das.
|
||||
$client = $this->login('publisher', $client);
|
||||
$token = $client->request('GET', '/admin')->filter('input[name="_csrf_token"]')->last()->attr('value');
|
||||
$client->request('POST', '/admin/pages/'.$live.'/restore', ['_csrf_token' => $token]);
|
||||
self::assertNull($this->db->fetchOne('SELECT deleted_at FROM page WHERE id = ?', [$live]));
|
||||
self::assertStringContainsString('wieder öffentlich', $client->followRedirect()->text());
|
||||
$client->request('POST', '/admin/pages/'.$live.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($live), 'slug' => 'umbenannt']);
|
||||
self::assertSame('umbenannt', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$live]));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\Scheduler;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use App\Plugin\PluginRuntime;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/** Plugins mit eigenem PHP-Code (ADR 127): laden, Hooks, Aufgaben, Seiten, Fehler eingrenzen. */
|
||||
final class PluginServerTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
private Connection $db;
|
||||
private PluginRegistry $plugins;
|
||||
private PluginRuntime $runtime;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->root = sys_get_temp_dir().'/kant-plugin-server-'.bin2hex(random_bytes(6));
|
||||
mkdir($this->root.'/plugins', 0o775, true);
|
||||
file_put_contents($this->root.'/VERSION', "1.39.0\n");
|
||||
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->root).'/test.db');
|
||||
TestSchema::build($this->db);
|
||||
$this->fresh();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($this->runtime, $this->plugins);
|
||||
$this->db->close();
|
||||
$remove = static function (string $path) use (&$remove): void {
|
||||
foreach (glob($path.'/*') ?: [] as $entry) {
|
||||
is_dir($entry) ? $remove($entry) : @unlink($entry);
|
||||
}
|
||||
@rmdir($path);
|
||||
};
|
||||
$remove($this->root);
|
||||
}
|
||||
|
||||
public function testAPluginRegistersHooksTasksPagesAndItsOwnTable(): void
|
||||
{
|
||||
$this->plugin('demo', <<<'PHP'
|
||||
$api->onInstall(fn () => $api->db()->executeStatement('CREATE TABLE IF NOT EXISTS '.$api->table('zeile').' (wert TEXT)'));
|
||||
$api->addFilter('liste', fn (array $w) => [...$w, 'spaet'], 20);
|
||||
$api->addFilter('liste', fn (array $w) => [...$w, 'frueh'], 5);
|
||||
$api->addTask('takt', 'Takt', 'Erklaerung', 600, fn () => 'gelaufen');
|
||||
$api->addPage('/objekte/{slug}', fn (array $p) => 'gibt-es' === $p['slug'] ? ['title' => 'T', 'description' => 'D', 'blocks' => [], 'status' => 410, 'robots' => 'noindex'] : null);
|
||||
$api->addData('zahl', fn (array $a) => $a['x'] * 2);
|
||||
PHP);
|
||||
$this->runtime->activate('demo');
|
||||
|
||||
$this->db->executeStatement('INSERT INTO plugin_demo_zeile (wert) VALUES (?)', ['ja']);
|
||||
self::assertSame(['frueh', 'spaet'], $this->runtime->applyFilters('liste', []));
|
||||
self::assertSame(['plugin-demo-takt'], array_map(static fn ($t) => $t->name(), $this->runtime->tasks()));
|
||||
self::assertSame('gelaufen', $this->runtime->tasks()[0]->run());
|
||||
self::assertSame(42, $this->runtime->data('demo', 'zahl', ['x' => 21]));
|
||||
|
||||
$seite = $this->runtime->page('/objekte/gibt-es');
|
||||
self::assertSame(410, $seite['status']);
|
||||
self::assertSame('noindex', $seite['robots']);
|
||||
self::assertSame('objekte/gibt-es', $seite['path']);
|
||||
self::assertNull($this->runtime->page('/objekte/gibt-es-nicht'));
|
||||
self::assertNull($this->runtime->page('/objekte/a/b'));
|
||||
|
||||
// Der Aufgabenplan kennt die Aufgabe, und nach dem Ausschalten nicht mehr.
|
||||
$plan = new Scheduler($this->db, [], $this->root, $this->runtime);
|
||||
$plan->pluginsChanged();
|
||||
self::assertSame('plugin-demo-takt', $this->db->fetchOne('SELECT name FROM scheduled_task'));
|
||||
self::assertSame('ok', $plan->runNow('plugin-demo-takt')['status']);
|
||||
$this->runtime->deactivate('demo');
|
||||
$plan->pluginsChanged();
|
||||
self::assertFalse($this->db->fetchOne('SELECT name FROM scheduled_task'));
|
||||
self::assertSame([], $this->runtime->applyFilters('liste', []));
|
||||
|
||||
// Eigene Tabellen überleben das Ausschalten.
|
||||
self::assertSame('ja', $this->db->fetchOne('SELECT wert FROM plugin_demo_zeile'));
|
||||
}
|
||||
|
||||
public function testAFaultyPluginStaysContained(): void
|
||||
{
|
||||
$this->plugin('kaputt', 'throw new \RuntimeException("Beim Laden gescheitert");');
|
||||
$this->plugin('wackel', <<<'PHP'
|
||||
$api->addFilter('wert', function ($w) { throw new \RuntimeException('Filterfehler'); });
|
||||
$api->addFilter('wert', fn ($w) => $w.'+gut', 20);
|
||||
$api->addPage('/wackel', function () { throw new \RuntimeException('Seitenfehler'); });
|
||||
PHP);
|
||||
try {
|
||||
$this->runtime->activate('kaputt');
|
||||
self::fail('Ein Plugin, das nicht lädt, darf nicht eingeschaltet bleiben.');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
self::assertStringContainsString('Beim Laden gescheitert', $e->getMessage());
|
||||
}
|
||||
self::assertFalse($this->plugins->all()['kaputt']['active']);
|
||||
|
||||
$this->runtime->activate('wackel');
|
||||
self::assertSame('x+gut', $this->runtime->applyFilters('wert', 'x'));
|
||||
self::assertNull($this->runtime->page('/wackel'));
|
||||
self::assertSame('Seitenfehler', $this->runtime->errors()['wackel']);
|
||||
}
|
||||
|
||||
public function testAFailedInstallLeavesThePluginOff(): void
|
||||
{
|
||||
$this->plugin('demo', '$api->onInstall(function () { throw new \RuntimeException("Keine Tabelle"); });');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
try {
|
||||
$this->runtime->activate('demo');
|
||||
} finally {
|
||||
self::assertFalse($this->plugins->all()['demo']['active']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testANewVersionRunsTheInstallAgainOnce(): void
|
||||
{
|
||||
$code = '$api->onInstall(fn () => $api->db()->executeStatement("INSERT INTO media (id, title, alt, variants, archived, version, created_at) VALUES (lower(hex(randomblob(16))), \'t\', \'\', \'[]\', 0, 1, \'2026-01-01 00:00:00\')"));';
|
||||
$this->plugin('demo', $code);
|
||||
$this->runtime->activate('demo');
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
|
||||
|
||||
$this->plugin('demo', $code, '1.1.0');
|
||||
$this->fresh();
|
||||
$this->runtime->tasks();
|
||||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
|
||||
$this->fresh();
|
||||
$this->runtime->tasks();
|
||||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
|
||||
}
|
||||
|
||||
public function testAPluginCannotTakeCoreAddressesOrOtherFolders(): void
|
||||
{
|
||||
$this->plugin('demo', '$api->addPage("/admin/{x}", fn () => null);');
|
||||
try {
|
||||
$this->runtime->activate('demo');
|
||||
self::fail('Die Verwaltung gehört dem Kern.');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
self::assertStringContainsString('gehört dem Kern', $e->getMessage());
|
||||
}
|
||||
file_put_contents($this->root.'/plugins/demo/plugin.json', json_encode(['name' => 'demo', 'title' => 'D', 'version' => '1.0.0', 'main' => '../fremd.php'], JSON_THROW_ON_ERROR));
|
||||
$this->fresh();
|
||||
self::assertNotNull($this->plugins->all()['demo']['error']);
|
||||
}
|
||||
|
||||
private function fresh(): void
|
||||
{
|
||||
$this->plugins = new PluginRegistry($this->db, $this->root);
|
||||
$this->runtime = new PluginRuntime($this->plugins, $this->db, new MediaLibrary($this->db, $this->root.'/media'));
|
||||
}
|
||||
|
||||
private function plugin(string $name, string $body, string $version = '1.0.0'): void
|
||||
{
|
||||
@mkdir($this->root.'/plugins/'.$name);
|
||||
file_put_contents($this->root.'/plugins/'.$name.'/plugin.json', json_encode(['name' => $name, 'title' => $name, 'version' => $version, 'main' => 'plugin.php'], JSON_THROW_ON_ERROR));
|
||||
file_put_contents($this->root.'/plugins/'.$name.'/plugin.php', "<?php\nreturn static function (\\App\\Plugin\\PluginApi \$api): void {\n".$body."\n};\n");
|
||||
clearstatcache();
|
||||
if (function_exists('opcache_invalidate')) {
|
||||
opcache_invalidate($this->root.'/plugins/'.$name.'/plugin.php', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user