1. YoureCom
    1. Über mich
  2. Lösungen
    1. Tipprunde
    2. Aufgaben & Support
    3. Mitglieder-Netzwerk
    4. Online-Shop
    5. Alle Lösungen & Preise
  3. Für Admins
    1. Plugins
    2. ByteMon
    3. ByteMon: Community verwalten
    4. ByteMon: Portalidentität
    5. Individuelle Entwicklung
    6. WSC 6.3 Alpha
    7. GitHub
  4. Neuigkeiten
    1. Kurzmeldungen
  5. Support
  6. Mein Konto
  7. Lösungen testen
  • Anmelden
  • Registrieren
  • Warenkorb 0
  • Suche
Alles
  • Alles
  • Artikel
  • Seiten
  • Aufgaben
  • Produkte
  • Pulse-Beiträge
  • Erweiterte Suche
  1. YoureCom – Software für Vereine, Teams & Communities
  2. Repository-Liste
  3. de.yourecom.wcf.api.mpdf

43 Dateien geändert: tests (22), (Hauptordner) (15), .github (5), files (1)

  • Marcel Beckers
  • -4 Löschungen
  • +3.340 Hinzufügungen
  • 3.344 Gesamt
  • 43 Dateien
  • 14. Oktober 2026 um 08:08
  • phpunit.xml
    <pre>@@ -0,0 +1,19 @@
    +<?xml version="1.0" encoding="UTF-8"?>
    +<!-- Unit tests without a WoltLab installation (also in the CI). -->
    +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    + xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    + bootstrap="tests/bootstrap.php"
    + colors="true"
    + cacheDirectory=".phpunit.cache"
    + failOnEmptyTestSuite="true">
    + <testsuites>
    + <testsuite name="unit">
    + <directory>tests/Unit</directory>
    + </testsuite>
    + </testsuites>
    + <source>
    + <include>
    + <directory>files/lib</directory>
    + </include>
    + </source>
    +</phpunit>
  • tests/Integration/bootstrap.php
    <pre>@@ -0,0 +1,21 @@
    +<?php
    +
    +/**
    + * Entry point of the integration suite. Loads the local WoltLab Suite installation, the tests use the plugin code
    + * installed there (deploy first), not the files of this repository.
    + */
    +
    +$wcfDir = \getenv('WCF_DIR');
    +$wcfDir = \rtrim(\is_string($wcfDir) && $wcfDir !== '' ? $wcfDir : 'C:/laragon/www/wsc6.2', '/\\');
    +if (!\is_file($wcfDir . '/global.php')) {
    + \fwrite(\STDERR, "No WoltLab Suite installation found in '{$wcfDir}', set WCF_DIR.\n");
    +
    + exit(1);
    +}
    +
    +$_SERVER['HTTP_HOST'] = 'localhost';
    +$_SERVER['SERVER_NAME'] = 'localhost';
    +$_SERVER['REQUEST_METHOD'] = 'GET';
    +require $wcfDir . '/global.php';
    +
    +require_once __DIR__ . '/../support/IntegrationTestCase.php';
  • tests/Unit/.gitkeep
  • tests/Unit/BundleTest.php
    <pre>@@ -0,0 +1,31 @@
    +<?php
    +
    +namespace yourecom\tests\mpdf;
    +
    +use PHPUnit\Framework\TestCase;
    +
    +final class BundleTest extends TestCase
    +{
    + private const BUNDLE = __DIR__ . '/../../files/lib/system/api/mpdf';
    +
    + public function testComposerJsonRequiresMpdf(): void
    + {
    + $composer = \json_decode((string)\file_get_contents(self::BUNDLE . '/composer.json'), true, 512, \JSON_THROW_ON_ERROR);
    +
    + self::assertIsArray($composer);
    + self::assertIsArray($composer['require'] ?? null);
    + self::assertArrayHasKey('mpdf/mpdf', $composer['require']);
    + }
    +
    + public function testAutoloadLoadsTheComposerAutoloader(): void
    + {
    + $autoload = (string)\file_get_contents(self::BUNDLE . '/autoload.php');
    +
    + self::assertStringContainsString("/vendor/autoload.php", $autoload);
    + }
    +
    + public function testLockFileIsCommitted(): void
    + {
    + self::assertFileExists(self::BUNDLE . '/composer.lock');
    + }
    +}
  • tests/bootstrap.php
    <pre>@@ -0,0 +1,26 @@
    +<?php
    +
    +/**
    + * Entry point of the PHPUnit unit suite. The unit tests run without a WoltLab installation, so only the plugin's own
    + * classes are loaded: `<namespace>\foo\Bar` => `<dir>/lib/foo/Bar.class.php`.
    + */
    +
    +// the plugin's own vendor (CI) or the shared tools of the plugins folder (local)
    +require \is_file(__DIR__ . '/../vendor/autoload.php') ? __DIR__ . '/../vendor/autoload.php' : __DIR__ . '/../../_dev/tools/vendor/autoload.php';
    +
    +\spl_autoload_register(static function (string $class): void {
    + $directories = ['Composer\\' => 'files', 'Mpdf\\' => 'files', 'DeepCopy\\' => 'files', 'Psr\\' => 'files', 'setasign\\' => 'files'];
    +
    + foreach ($directories as $prefix => $directory) {
    + if (!\str_starts_with($class, $prefix)) {
    + continue;
    + }
    +
    + $file = __DIR__ . '/../' . $directory . '/lib/' . \str_replace('\\', '/', \substr($class, \strlen($prefix))) . '.class.php';
    + if (\is_file($file)) {
    + require_once $file;
    +
    + return;
    + }
    + }
    +});
  • tests/coverage-minimum.txt
    <pre>@@ -0,0 +1 @@
    +0
  • tests/lint/check-architecture.php
    <pre>@@ -0,0 +1,204 @@
    +<?php
    +
    +/**
    + * YoureCom Standard: architecture rules (close to the core of WoltLab Suite 6.2). Managed by _dev/standard (apply.php),
    + * do not edit here.
    + * - list-page: lists are AbstractGridViewPage (ACP) or AbstractListViewPage, not SortablePage/MultipleLinkPage
    + * - object-action: no `{objectAction}`/`jsObjectActionContainer` in templates, actions are interactions with RPC endpoints
    + * - clipboard: no clipboard actions, mass actions are bulk interactions of the grid/list view
    + * - toggle-action: no IToggleAction/TDatabaseObjectToggle, enabling and disabling are RPC endpoints with a command
    + * - ajax-method: no custom `validate*()` AJAX entry points in AbstractDatabaseObjectAction classes (RPC endpoints)
    + * - endpoint-validate-action: RPC endpoints never call validateAction() (it rejects every name of `$requireACP`)
    + * - require-acp: create/update/delete cannot be called through AJAX from the frontend: in `$requireACP` unless the
    + * action denies them anyway (no `$permissions*` and no own `validate*()`)
    + * - acp-menu: ACP menu items are registered with the ItemCollecting event (listener in the bootstrap), acpMenu.xml
    + * only holds <delete> entries for removing old items
    + * - cache-builder: caches are eager (AbstractEagerCache) or tolerant (AbstractTolerantCache) caches of 6.2, not
    + * AbstractCacheBuilder/AbstractLegacyCacheBuilder
    + * - legacy-form: forms use the form builder (AbstractFormBuilderForm), not AbstractForm/AbstractAcpForm
    + * - listener-instanceof: event listener classes check `$eventObj` with instanceof
    + * - legacy-listener: eventListener.xml imports legacy listeners, use PSR-14 events and listeners registered in the
    + * bootstrap (keep only <delete>)
    + * - legacy-ajax: TypeScript, JavaScript and templates call DatabaseObjectActions (`WoltLabSuite/Core/Ajax`,
    + * `dboAction`, `className: "…\data\…Action"`, `WCF.Action.*`, `ajax-proxy`), use RPC endpoints
    + *
    + * Known findings that cannot be changed yet are listed in tests/lint/config.php (`architectureKnown`: relative file
    + * => list of rules); entries that no longer match make the check fail, so the list can only shrink.
    + *
    + * Usage: php tests/lint/check-architecture.php
    + */
    +
    +namespace yourecom\standard;
    +
    +require __DIR__ . '/lib.php';
    +
    +const LEGACY_LIST_PAGES = 'SortablePage|MultipleLinkPage|AbstractListPage';
    +
    +/** calls of DatabaseObjectActions from TypeScript, JavaScript and templates (the AJAX API before RPC endpoints) */
    +const LEGACY_AJAX = [
    + '~[\x22\x27]WoltLabSuite/Core/Ajax(?:/DboAction)?[\x22\x27]~' => 'WoltLabSuite/Core/Ajax',
    + '~\b(?:define|require)\s*\(\s*\[[^\]]*[\x22\x27]Ajax[\x22\x27]~' => 'AMD module Ajax',
    + '~\bdboAction\b~' => 'dboAction',
    + '~[\x22\x27][a-z]\w*(?:\x5C{1,2}\w+)*\x5C{1,2}data\x5C{1,2}(?:\w+\x5C{1,2})*\w+Action[\x22\x27]~' => 'class name of a DatabaseObjectAction',
    + '~\bWCF\.Action\.\w+~' => 'WCF.Action',
    + '~ajax-proxy~' => 'ajax-proxy',
    +];
    +
    +/** @var array<string, list<string>> $findings relative file => rules */
    +$findings = [];
    +/** @var array<string, string> $messages "file|rule" => message */
    +$messages = [];
    +
    +$report = static function (string $file, string $rule, string $message) use (&$findings, &$messages): void {
    + $findings[$file][] = $rule;
    + $messages["{$file}|{$rule}"] ??= "{$file}: [{$rule}] {$message}";
    +};
    +
    +foreach (files(['files', 'files_wcf'], ['.php']) as $relative => $path) {
    + $source = (string)\file_get_contents($path);
    +
    + if (\preg_match('~\bextends\s+\\\\?(?:wcf\\\\page\\\\)?(' . LEGACY_LIST_PAGES . ')\b~', $source, $match)) {
    + $report($relative, 'list-page', "extends {$match[1]}, use AbstractGridViewPage (ACP) or AbstractListViewPage with a grid/list view");
    + }
    +
    + if (\preg_match('~\bextends\s+\\\\?(?:wcf\\\\system\\\\clipboard\\\\action\\\\)?AbstractClipboardAction\b~', $source)) {
    + $report($relative, 'clipboard', 'clipboard action, use bulk interactions of the grid/list view');
    + }
    +
    + if (\preg_match('~\bextends\s+\\\\?(?:wcf\\\\system\\\\cache\\\\builder\\\\)?(?:AbstractCacheBuilder|AbstractLegacyCacheBuilder|AbstractSortedUserCacheBuilder)\b~', $source)) {
    + $report($relative, 'cache-builder', 'cache builder, use an eager (AbstractEagerCache) or tolerant (AbstractTolerantCache) cache');
    + }
    +
    + if (\preg_match('~\bextends\s+\\\\?(?:wcf\\\\(?:acp\\\\)?form\\\\)?(AbstractForm|AbstractAcpForm|AbstractCaptchaForm)\b~', $source, $match)) {
    + $report($relative, 'legacy-form', "extends {$match[1]}, use the form builder (AbstractFormBuilderForm)");
    + }
    +
    + if (
    + \preg_match('~\bimplements\b[^{]*\bIParameterizedEventListener\b|\bextends\s+\\\\?(?:wcf\\\\system\\\\event\\\\listener\\\\)?AbstractEventListener\b~', $source)
    + && !\str_contains($source, 'instanceof')
    + ) {
    + $report($relative, 'listener-instanceof', 'event listener without an instanceof check of $eventObj');
    + }
    +
    + // an RPC request is never an ACP request: validateAction() rejects every name in $requireACP there
    + if (\str_contains($relative, '/system/endpoint/') && \preg_match('~->validateAction\(\)~', $source)) {
    + $report($relative, 'endpoint-validate-action', 'RPC endpoint calls validateAction(), call the check directly (e.g. validateDelete()) or check in the endpoint/command');
    + }
    +
    + if (!\preg_match('~\bextends\s+\\\\?(?:wcf\\\\data\\\\)?AbstractDatabaseObjectAction\b~', $source)) {
    + continue;
    + }
    +
    + if (\preg_match('~\bIToggleAction\b|\bTDatabaseObjectToggle\b~', $source)) {
    + $report($relative, 'toggle-action', 'IToggleAction/TDatabaseObjectToggle, use enable/disable RPC endpoints with a command');
    + }
    +
    + \preg_match_all('~public\s+function\s+(validate\w+)\s*\(~', $source, $matches);
    + $custom = \array_diff($matches[1], ['validateCreate', 'validateUpdate', 'validateDelete']);
    + if ($custom !== []) {
    + $report($relative, 'ajax-method', 'AJAX entry points ' . \implode(', ', $custom) . '(), use RPC endpoints');
    + }
    +
    + // AJAX (AJAXProxyAction -> validateAction()) reaches create/update/delete from the frontend only if the name is not
    + // in $requireACP and the action can pass validate*(): with $permissions* set or an own validate*() method
    + // (the core's validate*() deny everything without $permissions*); RPC endpoints never go through this check
    + $requireAcp = [];
    + if (\preg_match('~\$requireACP\s*=\s*\[([^\]]*)\]~', $source, $match)) {
    + \preg_match_all('~[\'"](\w+)[\'"]~', $match[1], $names);
    + $requireAcp = $names[1];
    + }
    + $reachable = [];
    + foreach (['create', 'update', 'delete'] as $name) {
    + if (\in_array($name, $requireAcp, true)) {
    + continue;
    + }
    + $permissions = '~\$permissions' . \ucfirst($name) . '\s*=\s*\[\s*[\'"]~';
    + $validator = '~function\s+validate' . \ucfirst($name) . '\s*\(~';
    + if (\preg_match($permissions, $source) || \preg_match($validator, $source)) {
    + $reachable[] = $name;
    + }
    + }
    + if ($reachable !== []) {
    + $report($relative, 'require-acp', 'the frontend can call ' . \implode(', ', $reachable) . ' through AJAX, add them to $requireACP (RPC endpoints call validate' . \ucfirst($reachable[0]) . '() directly)');
    + }
    +}
    +
    +foreach (files(templateDirectories(), ['.tpl']) as $relative => $path) {
    + $source = (string)\file_get_contents($path);
    +
    + if (\preg_match('~\{objectAction\b|jsObjectActionContainer~', $source)) {
    + $report($relative, 'object-action', '{objectAction}/jsObjectActionContainer, use interactions (quick interactions for toggles)');
    + }
    +
    + if (\preg_match('~jsClipboard(?:Container|Item|Editor|MarkAll)|Controller/Clipboard~', $source)) {
    + $report($relative, 'clipboard', 'clipboard markup, use bulk interactions of the grid/list view');
    + }
    +}
    +
    +$legacyAjax = static function (string $relative, string $source) use ($report): void {
    + $found = [];
    + foreach (LEGACY_AJAX as $pattern => $label) {
    + if (\preg_match($pattern, $source)) {
    + $found[] = $label;
    + }
    + }
    + if ($found !== []) {
    + $report($relative, 'legacy-ajax', 'legacy AJAX (' . \implode(', ', $found) . '), use RPC endpoints');
    + }
    +};
    +
    +foreach (files(['ts'], ['.ts']) as $relative => $path) {
    + if (!\str_ends_with($relative, '.test.ts') && !\str_ends_with($relative, '.d.ts')) {
    + $legacyAjax($relative, (string)\file_get_contents($path));
    + }
    +}
    +
    +// hand-written JavaScript; the committed build of ts/ is checked through its sources
    +$jsBuildDir = config()['jsBuildDir'];
    +foreach (files(['files', 'files_wcf'], ['.js']) as $relative => $path) {
    + if (\str_ends_with($relative, '.min.js') || ($jsBuildDir !== null && \str_starts_with($relative, \rtrim($jsBuildDir, '/') . '/'))) {
    + continue;
    + }
    + $legacyAjax($relative, (string)\file_get_contents($path));
    +}
    +
    +// templates with {objectAction}/jsObjectActionContainer are already reported as object-action
    +foreach (files(templateDirectories(), ['.tpl']) as $relative => $path) {
    + if (!\in_array('object-action', $findings[$relative] ?? [], true)) {
    + $legacyAjax($relative, (string)\file_get_contents($path));
    + }
    +}
    +
    +$clipboardXml = ROOT . '/clipboardAction.xml';
    +if (\is_file($clipboardXml) && \str_contains((string)\file_get_contents($clipboardXml), '<import>')) {
    + $report('clipboardAction.xml', 'clipboard', 'installs clipboard actions, use bulk interactions (keep only <delete>)');
    +}
    +
    +$eventListenerXml = ROOT . '/eventListener.xml';
    +if (\is_file($eventListenerXml) && \str_contains((string)\file_get_contents($eventListenerXml), '<import>')) {
    + $report('eventListener.xml', 'legacy-listener', 'imports event listeners, use PSR-14 events and listeners registered in the bootstrap (keep only <delete>)');
    +}
    +
    +$acpMenuXml = ROOT . '/acpMenu.xml';
    +if (\is_file($acpMenuXml) && \str_contains((string)\file_get_contents($acpMenuXml), '<import>')) {
    + $report('acpMenu.xml', 'acp-menu', 'imports ACP menu items, register them with the ItemCollecting event (keep only <delete>)');
    +}
    +
    +$known = config()['architectureKnown'];
    +$errors = [];
    +foreach ($findings as $file => $rules) {
    + foreach (\array_unique($rules) as $rule) {
    + if (!\in_array($rule, $known[$file] ?? [], true)) {
    + $errors[] = $messages["{$file}|{$rule}"];
    + }
    + }
    +}
    +foreach ($known as $file => $rules) {
    + foreach ($rules as $rule) {
    + if (!\in_array($rule, $findings[$file] ?? [], true)) {
    + $errors[] = "tests/lint/config.php: architectureKnown {$file} [{$rule}] no longer applies, remove it";
    + }
    + }
    +}
    +
    +finish($errors, \count($findings) === 0 ? 'no legacy patterns' : \count($known) . ' known');
  • tests/lint/check-coverage.php
    <pre>@@ -0,0 +1,32 @@
    +<?php
    +
    +/**
    + * YoureCom Standard: line coverage of the PHPUnit unit suite (Clover report) against the floor in
    + * tests/coverage-minimum.txt. The floor only goes up. Managed by _dev/standard (apply.php), do not edit here.
    + *
    + * Usage: php tests/lint/check-coverage.php var/coverage.xml
    + */
    +
    +namespace yourecom\standard;
    +
    +require __DIR__ . '/lib.php';
    +
    +$report = $argv[1] ?? 'var/coverage.xml';
    +if (!\is_file($report)) {
    + fail(["The coverage report {$report} does not exist (run PHPUnit with --coverage-clover first)"]);
    +}
    +
    +$xml = \simplexml_load_file($report);
    +if ($xml === false || !isset($xml->project->metrics)) {
    + fail(['The coverage report cannot be read']);
    +}
    +
    +$statements = (int)$xml->project->metrics['statements'];
    +$covered = (int)$xml->project->metrics['coveredstatements'];
    +$percent = $statements > 0 ? 100 * $covered / $statements : 100.0;
    +$minimum = (float)\trim((string)@\file_get_contents(ROOT . '/tests/coverage-minimum.txt'));
    +
    +\printf("Coverage of the unit suite: %.2f%% (%d of %d statements), floor %.2f%%\n", $percent, $covered, $statements, $minimum);
    +if ($percent + 0.005 < $minimum) {
    + fail(['The coverage is below the floor.']);
    +}
  • tests/lint/check-deletes.php
    <pre>@@ -0,0 +1,434 @@
    +<?php
    +
    +/**
    + * YoureCom Standard: removed files and language items are deleted on update. Managed by _dev/standard (apply.php),
    + * do not edit here.
    + * - every file of a released version that the package does not ship any more is listed in fileDelete.xml
    + * (files/, files_wcf/), templateDelete.xml (templates/) or acpTemplateDelete.xml (acptemplates/)
    + * - every language item of a released version that is gone is listed in the <delete> block of each language file
    + * - nothing that the package still ships is deleted (a file moved from files/ to files_wcf/ keeps its target)
    + *
    + * A released version is the state of the git history before <version> in package.xml changed (and the first commit),
    + * as long as package.xml names this package: the history of another package the repository started from does not
    + * count. Only those files can be on the systems of the customers; the core also deletes files that no package owns.
    + *
    + * Needs the full git history (CI: actions/checkout with fetch-depth: 0).
    + *
    + * Usage: php tests/lint/check-deletes.php [--fix] --fix adds the missing entries (and the update instructions)
    + */
    +
    +namespace yourecom\standard;
    +
    +require __DIR__ . '/lib.php';
    +
    +const PIPS = [
    + 'files' => ['fileDelete', 'file'],
    + 'files_wcf' => ['fileDelete', 'file'],
    + 'templates' => ['templateDelete', 'template'],
    + 'acptemplates' => ['acpTemplateDelete', 'template'],
    +];
    +
    +$fix = \in_array('--fix', $argv, true);
    +
    +if (git(['rev-parse', '--is-inside-work-tree']) === null) {
    + fail(['not a git repository, the delete check needs the history']);
    +}
    +if (git(['rev-parse', '--is-shallow-repository']) === 'true') {
    + fail(['shallow clone, the delete check needs the full history (actions/checkout with fetch-depth: 0)']);
    +}
    +
    +$applications = applications();
    +$errors = [];
    +
    +// what the package ships now: pip => "application|entry" => true
    +$shipped = [];
    +foreach (\explode("\n", (string)git(['-c', 'core.quotePath=false', 'ls-files'])) as $path) {
    + if (($target = deleteTarget($path, $applications)) !== null) {
    + $shipped[$target[0]]["{$target[1]}|{$target[2]}"] = true;
    + }
    +}
    +
    +$releases = releases();
    +
    +// what a released version shipped and the package does not ship any more: pip => "application|entry" => [application, entry]
    +$removed = [];
    +foreach (releasedFiles($releases) as $path) {
    + $target = deleteTarget($path, $applications);
    + if ($target !== null && !isset($shipped[$target[0]]["{$target[1]}|{$target[2]}"])) {
    + $removed[$target[0]]["{$target[1]}|{$target[2]}"] = [$target[1], $target[2]];
    + }
    +}
    +
    +foreach (['fileDelete' => 'file', 'templateDelete' => 'template', 'acpTemplateDelete' => 'template'] as $pip => $element) {
    + // the core appends .tpl itself, an entry "name.tpl" looks for "name.tpl.tpl"
    + if ($element === 'template' && \is_file(ROOT . "/{$pip}.xml")) {
    + $source = (string)\file_get_contents(ROOT . "/{$pip}.xml");
    + if (\preg_match_all('~<template\b[^>]*>([^<]*)\.tpl</template>~', $source, $suffixed) > 0) {
    + if ($fix) {
    + \file_put_contents(ROOT . "/{$pip}.xml", (string)\preg_replace('~(<template\b[^>]*>[^<]*)\.tpl(</template>)~', '$1$2', $source));
    + echo "{$pip}.xml: removed .tpl from " . \count($suffixed[1]) . " entries\n";
    + } else {
    + foreach ($suffixed[1] as $name) {
    + $errors[] = "{$pip}.xml: {$name}.tpl, the core appends .tpl itself (write {$name})";
    + }
    + }
    + }
    + }
    +
    + $listed = deleteEntries($pip, $element, $applications);
    + $missing = [];
    + foreach ($removed[$pip] ?? [] as [$application, $entry]) {
    + if (!isset($listed["{$application}|{$entry}"]) && !isset($listed["|{$entry}"])) {
    + $missing[] = [$application, $entry];
    + }
    + }
    + foreach ($listed as $key => $label) {
    + [$application, $entry] = \explode('|', $key, 2);
    + if (isset($shipped[$pip]["{$application}|{$entry}"]) || ($application === '' && isShippedAnywhere($shipped[$pip] ?? [], $entry))) {
    + $errors[] = "{$pip}.xml: deletes {$label}, which the package still ships";
    + }
    + }
    + if ($missing === []) {
    + continue;
    + }
    +
    + if ($fix) {
    + addDeleteEntries($pip, $element, $missing);
    + addUpdateInstruction($pip);
    + echo "{$pip}.xml: added " . \count($missing) . " entr" . (\count($missing) === 1 ? 'y' : 'ies') . "\n";
    + } else {
    + foreach ($missing as [$application, $entry]) {
    + $errors[] = "{$pip}.xml: {$entry} ({$application}) was removed, delete it on update";
    + }
    + }
    +}
    +
    +// language items of the released versions
    +$languageFiles = \glob(ROOT . '/language/*.xml') ?: [];
    +$requests = [];
    +foreach (\array_keys($releases) as $commit) {
    + foreach ($languageFiles as $file) {
    + $requests[] = "{$commit}:language/" . \basename($file);
    + }
    +}
    +$removedItems = [];
    +// many releases share the same version of a language file: read every version once
    +$blobs = \array_values(\array_unique(\array_filter(gitObjects($requests, contents: false))));
    +foreach (gitObjects($blobs) as $source) {
    + \preg_match_all('~<item name="([^"]+)"~', (string)\preg_replace('~<delete>.*?</delete>~s', '', (string)$source), $matches);
    + foreach ($matches[1] as $name) {
    + // WoltLab rejects names with leading or trailing whitespace, such an item never existed in a database
    + if ($name === \trim($name)) {
    + $removedItems[$name] = true;
    + }
    + }
    +}
    +foreach ($languageFiles as $file) {
    + $languageFile = 'language/' . \basename($file);
    + $language = xml($file);
    + if ($language === null) {
    + continue;
    + }
    + $imported = [];
    + foreach ($language->query('//w:category/w:item') ?: [] as $item) {
    + $imported[$item instanceof \DOMElement ? $item->getAttribute('name') : ''] = true;
    + }
    + $deleted = [];
    + foreach ($language->query('/w:language/w:delete/w:item') ?: [] as $item) {
    + $name = $item instanceof \DOMElement ? $item->getAttribute('name') : '';
    + $deleted[$name] = true;
    + if (isset($imported[$name])) {
    + $errors[] = "{$languageFile}: deletes {$name}, which the file still imports";
    + }
    + }
    +
    + $missing = \array_keys(\array_diff_key($removedItems, $imported, $deleted));
    + \sort($missing);
    + if ($missing === []) {
    + continue;
    + }
    + if ($language->query('/w:language/w:import')?->length === 0) {
    + $errors[] = "{$languageFile}: uses the old format without <import>, convert it to delete the removed items " . \implode(', ', $missing);
    + } elseif ($fix) {
    + addLanguageDeletes($file, $missing);
    + echo "{$languageFile}: added " . \count($missing) . " delete entr" . (\count($missing) === 1 ? 'y' : 'ies') . "\n";
    + } else {
    + foreach ($missing as $name) {
    + $errors[] = "{$languageFile}: the item {$name} was removed, delete it on update (<delete><item name=\"{$name}\" /></delete>)";
    + }
    + }
    +}
    +
    +finish($errors, \count($releases) . ' released versions');
    +
    +/**
    + * Returns the commits of the released versions of this package (commit => true): the first commit and the commit
    + * before every change of <version>, on the first-parent line, where package.xml names this package.
    + *
    + * @return array<string, true>
    + */
    +function releases(): array
    +{
    + $commits = \array_values(\array_filter(\explode("\n", (string)git(['rev-list', '--first-parent', '--reverse', 'HEAD']))));
    + if ($commits === []) {
    + return [];
    + }
    + $order = \array_flip($commits);
    +
    + $candidates = [$commits[0] => true];
    + $changes = \array_filter(\explode("\n", (string)git(['log', '--first-parent', '--format=%H', '-G<version>', '--', 'package.xml'])));
    + foreach ($changes as $change) {
    + $index = $order[$change] ?? 0;
    + if ($index > 0) {
    + $candidates[$commits[$index - 1]] = true;
    + }
    + }
    +
    + $name = packageName();
    + $candidates = \array_keys($candidates);
    + $releases = [];
    + foreach (gitObjects(\array_map(static fn (string $commit): string => "{$commit}:package.xml", $candidates)) as $index => $source) {
    + if ($source !== null && \preg_match('~<package name="([^"]+)"~', $source, $match) && \strcasecmp($match[1], $name) === 0) {
    + $releases[$candidates[$index]] = true;
    + }
    + }
    +
    + // in the order of the history
    + \uksort($releases, static fn (string $a, string $b): int => $order[$a] <=> $order[$b]);
    +
    + return $releases;
    +}
    +
    +/**
    + * Returns the paths below files/, files_wcf/, templates/ and acptemplates/ that one of the releases contained.
    + * Replays the changes of the first-parent history once instead of listing the tree of every release.
    + *
    + * @param array<string, true> $releases in the order of the history
    + * @return list<string>
    + */
    +function releasedFiles(array $releases): array
    +{
    + $pending = \array_keys($releases);
    + $log = (string)git(['-c', 'core.quotePath=false', 'log', '--first-parent', '--diff-merges=first-parent', '--reverse', '--relative', '--no-renames', '--name-status', '--format=>%H', '--', ...\array_keys(PIPS)]);
    + $commits = \array_flip(\array_values(\array_filter(\explode("\n", (string)git(['rev-list', '--first-parent', '--reverse', 'HEAD'])))));
    +
    + $state = [];
    + $released = [];
    + foreach (\explode("\n", $log) as $line) {
    + if (\str_starts_with($line, '>')) {
    + // a release before this commit sees the state up to here
    + $index = $commits[\substr($line, 1)] ?? \PHP_INT_MAX;
    + while ($pending !== [] && ($commits[$pending[0]] ?? -1) < $index) {
    + $released += $state;
    + \array_shift($pending);
    + }
    + continue;
    + }
    + $parts = \explode("\t", $line, 2);
    + if (\count($parts) !== 2) {
    + continue;
    + }
    + if ($parts[0] === 'D') {
    + unset($state[$parts[1]]);
    + } else {
    + $state[$parts[1]] = true;
    + }
    + }
    + if ($pending !== []) {
    + $released += $state;
    + }
    +
    + return \array_map('strval', \array_keys($released));
    +}
    +
    +/**
    + * Reads objects like "<commit>:<path>" from the repository in one git process: their contents, or with
    + * `contents: false` their object ids; null for a missing object.
    + *
    + * @param list<string> $objects
    + * @return list<string|null>
    + */
    +function gitObjects(array $objects, bool $contents = true): array
    +{
    + if ($objects === []) {
    + return [];
    + }
    +
    + // requests and answer go through files: a pipe could block while git writes, and pipes are slow on Windows
    + $requests = (string)\tempnam(\sys_get_temp_dir(), 'yourecom-git-');
    + $answer = (string)\tempnam(\sys_get_temp_dir(), 'yourecom-git-');
    + \file_put_contents($requests, \implode("\n", $objects) . "\n");
    + $process = \proc_open(
    + ['git', '-C', (string)\realpath(ROOT), 'cat-file', $contents ? '--batch' : '--batch-check'],
    + [0 => ['file', $requests, 'r'], 1 => ['file', $answer, 'w'], 2 => ['file', \PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'w']],
    + $pipes
    + );
    + if (!\is_resource($process)) {
    + \unlink($requests);
    + \unlink($answer);
    + fail(['git cat-file failed']);
    + }
    + \proc_close($process);
    + $output = (string)\file_get_contents($answer);
    + \unlink($requests);
    + \unlink($answer);
    +
    + $results = [];
    + $offset = 0;
    + foreach ($objects as $object) {
    + $end = \strpos($output, "\n", $offset);
    + if ($end === false) {
    + $results[] = null;
    + continue;
    + }
    + $header = \substr($output, $offset, $end - $offset);
    + $offset = $end + 1;
    + if (!\preg_match('~^([0-9a-f]+) \w+ (\d+)$~', $header, $match)) {
    + $results[] = null;
    + continue;
    + }
    + if (!$contents) {
    + $results[] = $match[1];
    + continue;
    + }
    + $results[] = \substr($output, $offset, (int)$match[2]);
    + $offset += (int)$match[2] + 1;
    + }
    +
    + return $results;
    +}
    +
    +/**
    + * Returns the delete PIP, the application and the entry for a path of the repository, null if it is not installed.
    + *
    + * @param array<string, string> $applications
    + * @return array{string, string, string}|null
    + */
    +function deleteTarget(string $path, array $applications): ?array
    +{
    + $parts = \explode('/', $path, 2);
    + if (\count($parts) !== 2 || !isset(PIPS[$parts[0]])) {
    + return null;
    + }
    + [$directory, $relative] = $parts;
    + if (PIPS[$directory][1] === 'template') {
    + if (!\str_ends_with($relative, '.tpl') || \str_contains($relative, '/')) {
    + return null;
    + }
    + $relative = \substr($relative, 0, -4);
    + }
    +
    + return [PIPS[$directory][0], $applications[$directory], $relative];
    +}
    +
    +/**
    + * Returns the entries of a delete PIP file: "application|entry" => label (application empty if not given).
    + *
    + * @param array<string, string> $applications
    + * @return array<string, string>
    + */
    +function deleteEntries(string $pip, string $element, array $applications): array
    +{
    + $entries = [];
    + foreach (xml(ROOT . "/{$pip}.xml")?->query("/w:data/w:delete/w:{$element}") ?: [] as $node) {
    + if ($node instanceof \DOMElement) {
    + $entry = \trim($node->textContent);
    + $entries[$node->getAttribute('application') . '|' . $entry] = $entry . ($node->getAttribute('application') !== '' ? " ({$node->getAttribute('application')})" : '');
    + }
    + }
    +
    + return $entries;
    +}
    +
    +/**
    + * Tells whether an entry without application is shipped into any application.
    + *
    + * @param array<string, true> $shipped
    + */
    +function isShippedAnywhere(array $shipped, string $entry): bool
    +{
    + foreach (\array_keys($shipped) as $key) {
    + if (\explode('|', $key, 2)[1] === $entry) {
    + return true;
    + }
    + }
    +
    + return false;
    +}
    +
    +/**
    + * Adds entries to a delete PIP file (creates it if needed), keeps its line endings and indentation.
    + *
    + * @param list<array{string, string}> $entries [application, entry]
    + */
    +function addDeleteEntries(string $pip, string $element, array $entries): void
    +{
    + $file = ROOT . "/{$pip}.xml";
    + $source = \is_file($file) ? (string)\file_get_contents($file) : "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
    + . "<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com https://www.woltlab.com/XSD/6.0/{$pip}.xsd\">\n"
    + . "</data>\n";
    + $eol = \str_contains($source, "\r\n") ? "\r\n" : "\n";
    + $indent = \preg_match('~^([ \t]+)<delete>~m', $source, $match) ? $match[1] : "\t";
    + \usort($entries, static fn (array $a, array $b): int => [$a[0], $a[1]] <=> [$b[0], $b[1]]);
    + $lines = \array_map(
    + static fn (array $entry): string => "{$indent}{$indent}<{$element} application=\"{$entry[0]}\">" . \htmlspecialchars($entry[1], \ENT_XML1) . "</{$element}>",
    + $entries
    + );
    +
    + \file_put_contents($file, insertLines($source, $lines, 'data', $indent, $eol));
    +}
    +
    +/**
    + * Inserts lines at the end of the <delete> block, or adds the block before the closing root tag.
    + *
    + * @param list<string> $lines
    + */
    +function insertLines(string $source, array $lines, string $root, string $indent, string $eol): string
    +{
    + $block = \implode($eol, $lines);
    + if (\str_contains($source, '</delete>')) {
    + return (string)\preg_replace_callback('~([ \t]*)</delete>~', static fn (array $match): string => $block . $eol . $match[1] . '</delete>', $source, 1);
    + }
    +
    + return (string)\preg_replace_callback("~</{$root}>~", static fn (): string => "{$indent}<delete>{$eol}{$block}{$eol}{$indent}</delete>{$eol}</{$root}>", $source, 1);
    +}
    +
    +/**
    + * Adds `<instruction type="..." />` to every update block of package.xml that does not have it.
    + */
    +function addUpdateInstruction(string $pip): void
    +{
    + $file = ROOT . '/package.xml';
    + $source = (string)\file_get_contents($file);
    + $eol = \str_contains($source, "\r\n") ? "\r\n" : "\n";
    + $updated = (string)\preg_replace_callback(
    + '~(<instructions\s+type="update"[^>]*>)(.*?)(\r?\n[ \t]*</instructions>)~s',
    + static function (array $block) use ($pip, $eol): string {
    + if (\str_contains($block[2], "type=\"{$pip}\"")) {
    + return $block[0];
    + }
    + $indent = \preg_match('~\n([ \t]+)<instruction\b~', $block[2], $match) ? $match[1] : "\t\t";
    +
    + return $block[1] . $block[2] . "{$eol}{$indent}<instruction type=\"{$pip}\" />" . $block[3];
    + },
    + $source
    + );
    + if ($updated !== $source) {
    + \file_put_contents($file, $updated);
    + }
    +}
    +
    +/**
    + * Adds `<item name="..."/>` entries to the <delete> block of a language file (creates the block if needed).
    + *
    + * @param list<string> $names
    + */
    +function addLanguageDeletes(string $file, array $names): void
    +{
    + $source = (string)\file_get_contents($file);
    + $eol = \str_contains($source, "\r\n") ? "\r\n" : "\n";
    + $indent = \preg_match('~^([ \t]+)<import>~m', $source, $match) ? $match[1] : "\t";
    + $lines = \array_map(static fn (string $name): string => "{$indent}{$indent}<item name=\"" . \htmlspecialchars($name, \ENT_XML1) . '" />', $names);
    +
    + \file_put_contents($file, insertLines($source, $lines, 'language', $indent, $eol));
    +}
  • tests/lint/check-devsystem.php
    <pre>@@ -0,0 +1,117 @@
    +<?php
    +
    +/**
    + * YoureCom Standard: checks against the local dev system. Managed by _dev/standard (apply.php), do not edit here.
    + * - every permission the package asks for exists (a typo locks everybody out without an error): getPermission(),
    + * checkPermissions() and the $neededPermissions/$permissions* properties in PHP and templates, <permissions> in
    + * the PIP files; known are the user group options of the dev system and of userGroupOption.xml
    + * - every template and every language item compiles with the template compiler of the core
    + *
    + * Needs the dev system (environment variable WCF_DIR, default C:/laragon/www/wsc6.2), so it is not part of the CI;
    + * check.sh runs it unless --fast is given.
    + *
    + * Usage: php tests/lint/check-devsystem.php
    + */
    +
    +namespace yourecom\standard;
    +
    +use wcf\system\language\LanguageFactory;
    +use wcf\system\WCF;
    +
    +require __DIR__ . '/lib.php';
    +
    +$wcfDir = \getenv('WCF_DIR');
    +$wcfDir = \rtrim(\is_string($wcfDir) && $wcfDir !== '' ? $wcfDir : 'C:/laragon/www/wsc6.2', '/\\');
    +if (!\is_file($wcfDir . '/global.php')) {
    + fail(["No WoltLab Suite installation found in '{$wcfDir}', set WCF_DIR."]);
    +}
    +$_SERVER['HTTP_HOST'] = 'localhost';
    +$_SERVER['SERVER_NAME'] = 'localhost';
    +$_SERVER['REQUEST_METHOD'] = 'GET';
    +require $wcfDir . '/global.php';
    +
    +$errors = [];
    +
    +// permissions: name => list of places
    +$used = [];
    +$collect = static function (string $fragment, string $place) use (&$used): void {
    + \preg_match_all('~[\'"]([a-z]+(?:\.\w+)+)[\'"]~', $fragment, $names);
    + foreach ($names[1] as $name) {
    + $used[$name][] = $place;
    + }
    +};
    +foreach (files(['files', 'files_wcf', 'templates', 'acptemplates'], ['.php', '.tpl']) as $relative => $path) {
    + $source = (string)\file_get_contents($path);
    + \preg_match_all(
    + '~\b(?:getPermission|getNeutralPermission|checkPermissions|hasPermission)\s*\(\s*(\[[^\]]*\]|\'[^\']*\'|"[^"]*")'
    + . '|\$(?:neededPermissions|permissions(?:Create|Update|Delete|View)?)\s*=\s*(\[[^\]]*\])~',
    + $source,
    + $matches,
    + \PREG_SET_ORDER | \PREG_OFFSET_CAPTURE
    + );
    + foreach ($matches as $match) {
    + $line = \substr_count($source, "\n", 0, $match[0][1]) + 1;
    + $collect($match[0][0], "{$relative}:{$line}");
    + }
    +}
    +foreach (\glob(ROOT . '/*.xml') ?: [] as $file) {
    + $source = (string)\file_get_contents($file);
    + \preg_match_all('~<permissions>([^<]*)</permissions>~', $source, $matches, \PREG_OFFSET_CAPTURE);
    + foreach ($matches[1] as [$list, $offset]) {
    + $line = \substr_count($source, "\n", 0, $offset) + 1;
    + foreach (\array_filter(\array_map('trim', \explode(',', $list))) as $name) {
    + $used[$name][] = \basename($file) . ":{$line}";
    + }
    + }
    +}
    +
    +$known = [];
    +$statement = WCF::getDB()->prepare('SELECT optionName FROM wcf1_user_group_option');
    +$statement->execute();
    +foreach ($statement->fetchAll(\PDO::FETCH_COLUMN) as $name) {
    + $known[(string)$name] = true;
    +}
    +// options of this package that are not deployed yet
    +foreach (xml(ROOT . '/userGroupOption.xml')?->query('//w:option') ?: [] as $option) {
    + if ($option instanceof \DOMElement) {
    + $known[$option->getAttribute('name')] = true;
    + }
    +}
    +
    +\ksort($used);
    +foreach ($used as $name => $places) {
    + if (!isset($known[$name])) {
    + $errors[] = "unknown permission {$name} (" . \implode(', ', \array_unique($places)) . ')';
    + }
    +}
    +
    +// templates
    +$compiler = WCF::getTPL()->getCompiler();
    +$templates = 0;
    +foreach (files(templateDirectories(), ['.tpl']) as $relative => $path) {
    + $templates++;
    + try {
    + $compiler->compileString(\basename($path, '.tpl'), (string)\file_get_contents($path), [], true);
    + } catch (\Throwable $exception) {
    + $errors[] = "{$relative}: does not compile: " . \trim($exception->getMessage());
    + }
    +}
    +
    +// language items
    +$languageCompiler = LanguageFactory::getInstance()->getScriptingCompiler();
    +$items = 0;
    +foreach (['de', 'en'] as $languageCode) {
    + foreach (xml(ROOT . "/language/{$languageCode}.xml")?->query('//w:category/w:item') ?: [] as $item) {
    + if (!$item instanceof \DOMElement || !\str_contains($item->textContent, '{')) {
    + continue;
    + }
    + $items++;
    + try {
    + $languageCompiler->compileString($item->getAttribute('name'), $item->textContent, [], true);
    + } catch (\Throwable $exception) {
    + $errors[] = "language/{$languageCode}.xml: {$item->getAttribute('name')} does not compile: " . \trim($exception->getMessage());
    + }
    + }
    +}
    +
    +finish($errors, \count($used) . " permissions, {$templates} templates, {$items} language items");
  • tests/lint/check-endpoints.php
    <pre>@@ -0,0 +1,39 @@
    +<?php
    +
    +/**
    + * YoureCom Standard: every RPC endpoint (class with a request attribute below `system/endpoint/controller`) is
    + * registered in the bootstrap file, the core does not discover controllers on its own.
    + * Managed by _dev/standard (apply.php), do not edit here.
    + *
    + * Usage: php tests/lint/check-endpoints.php
    + */
    +
    +namespace yourecom\standard;
    +
    +require __DIR__ . '/lib.php';
    +
    +$bootstrap = bootstrap();
    +$errors = [];
    +$count = 0;
    +
    +foreach (files(['files', 'files_wcf'], ['.class.php']) as $relative => $path) {
    + if (!\str_contains($relative, '/system/endpoint/controller/')) {
    + continue;
    + }
    +
    + $source = (string)\file_get_contents($path);
    + if (!\preg_match('~#\[(?:Get|Post|Put|Patch|Delete)Request\(~', $source)
    + || !\preg_match('~^namespace ([^;]+);~m', $source, $namespace)
    + || !\preg_match('~^(?:final )?class (\w+)~m', $source, $class)) {
    + continue;
    + }
    +
    + $count++;
    + $fqcn = $namespace[1] . '\\' . $class[1];
    + $short = $class[1] . '()';
    + if (!\str_contains($bootstrap, $fqcn) && !\str_contains($bootstrap, 'new ' . $short)) {
    + $errors[] = "endpoint not registered in the bootstrap: {$fqcn}";
    + }
    +}
    +
    +finish($errors, "{$count} endpoints");
  • tests/lint/check-js-build.mjs
    <pre>@@ -0,0 +1,64 @@
    +// YoureCom Standard: the committed JavaScript (`jsBuildDir` of tests/lint/config.php, e.g. files/js) is exactly what the
    +// TypeScript sources produce. Managed by _dev/standard (apply.php), do not edit here.
    +import { execFileSync } from "node:child_process";
    +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
    +import { tmpdir } from "node:os";
    +import { join, relative } from "node:path";
    +import { fileURLToPath } from "node:url";
    +
    +const root = fileURLToPath(new URL("../..", import.meta.url));
    +const configSource = readFileSync(join(root, "tests/lint/config.php"), "utf8");
    +const buildDir = /'jsBuildDir'\s*=>\s*'([^']+)'/.exec(configSource)?.[1];
    +
    +function files(dir) {
    + return readdirSync(dir).flatMap((name) => {
    + const path = join(dir, name);
    + return statSync(path).isDirectory() ? files(path) : [path];
    + });
    +}
    +
    +if (!buildDir) {
    + // without jsBuildDir nothing would be checked, so TypeScript sources require it
    + const sources = existsSync(join(root, "ts"))
    + ? files(join(root, "ts")).filter((file) => file.endsWith(".ts") && !file.endsWith(".d.ts") && !file.endsWith(".test.ts"))
    + : [];
    + if (sources.length > 0) {
    + console.error(`ts/ has ${sources.length} TypeScript files, but tests/lint/config.php sets no jsBuildDir (e.g. 'files/js').`);
    + process.exit(1);
    + }
    + console.log("OK (no TypeScript)");
    + process.exit(0);
    +}
    +
    +const tsc = [join(root, "node_modules/typescript/bin/tsc"), join(root, "../node_modules/typescript/bin/tsc")].find(existsSync);
    +const out = mkdtempSync(join(tmpdir(), "yourecom-js-"));
    +
    +function read(dir) {
    + return new Map(files(dir).map((file) => [relative(dir, file).replaceAll("\\", "/"), readFileSync(file, "utf8").replaceAll("\r\n", "\n")]));
    +}
    +
    +try {
    + // tsconfig.ci.json resolves the types from the plugin's node_modules (CI), tsconfig.json from ../node_modules (local)
    + const project = existsSync(join(root, "node_modules/@woltlab/d.ts")) ? "tsconfig.ci.json" : "tsconfig.json";
    + execFileSync(process.execPath, [tsc, "-p", project, "--noEmit", "false", "--outDir", out], { cwd: root, stdio: "inherit" });
    +
    + const fresh = read(out);
    + const committed = existsSync(join(root, buildDir)) ? read(join(root, buildDir)) : new Map();
    + const problems = [];
    + for (const [name, content] of fresh) {
    + if (!committed.has(name)) problems.push(`missing in ${buildDir}: ${name}`);
    + else if (committed.get(name) !== content) problems.push(`outdated: ${buildDir}/${name}`);
    + }
    + for (const name of committed.keys()) {
    + if (name.endsWith(".js") && !fresh.has(name) && !name.startsWith("3rdParty/")) problems.push(`no TypeScript source for: ${buildDir}/${name}`);
    + }
    +
    + if (problems.length > 0) {
    + console.error(problems.join("\n"));
    + console.error("Run `npm run build` and commit the result.");
    + process.exit(1);
    + }
    + console.log(`OK (${fresh.size} files)`);
    +} finally {
    + rmSync(out, { recursive: true, force: true });
    +}
  • tests/lint/check-js-phrases.php
    <pre>@@ -0,0 +1,79 @@
    +<?php
    +
    +/**
    + * YoureCom Standard: every phrase the TypeScript modules read with getPhrase() exists in both language files and is
    + * preloaded, either in the bootstrap (`PreloadPhrasesCollecting`, preferred) or with `{jsphrase}` / `registerPhrase()` in a template.
    + * With `jsPhrasesDynamic` in tests/lint/config.php, phrases whose key is built at runtime are tolerated: calls without a
    + * literal name and preloaded phrases without a literal use are no errors then.
    + * Managed by _dev/standard (apply.php), do not edit here.
    + *
    + * Usage: php tests/lint/check-js-phrases.php
    + */
    +
    +namespace yourecom\standard;
    +
    +require __DIR__ . '/lib.php';
    +
    +\preg_match_all('~->preload\(\s*[\'"]([^\'"]+)[\'"]~', bootstrap(), $matches);
    +$preloaded = \array_fill_keys($matches[1], 'bootstrap');
    +foreach (files(templateDirectories(), ['.tpl']) as $relative => $path) {
    + \preg_match_all('~\{jsphrase name=[\'"]([^\'"]+)[\'"]~', (string)\file_get_contents($path), $matches);
    + $preloaded += \array_fill_keys($matches[1], $relative);
    + \preg_match_all('~registerPhrase\(\s*[\'"]([^\'"]+)[\'"]~', (string)\file_get_contents($path), $matches);
    + $preloaded += \array_fill_keys($matches[1], $relative);
    +}
    +
    +// phrases whose key is built at runtime cannot be followed, tests/lint/config.php: 'jsPhrasesDynamic' => true
    +$dynamicKeys = (bool)(config()['jsPhrasesDynamic'] ?? false);
    +
    +$dynamicKeys = (bool)(config()['jsPhrasesDynamic'] ?? false);
    +$languages = ['de' => \array_flip(languageKeys('de')), 'en' => \array_flip(languageKeys('en'))];
    +$used = [];
    +$errors = [];
    +
    +foreach (files(['ts'], ['.ts']) as $relative => $path) {
    + \preg_match_all('~getPhrase\(([^)]*)\)~', (string)\file_get_contents($path), $calls);
    + foreach ($calls[1] as $arguments) {
    + if (!\preg_match_all('~["\']([a-z]+\.[\w.]+)["\']~', $arguments, $keys)) {
    + if ($dynamicKeys) {
    + continue;
    + }
    +
    + $errors[] = "{$relative}: getPhrase() without a literal phrase name, the check cannot follow it";
    + continue;
    + }
    +
    + foreach ($keys[1] as $key) {
    + $used[$key] = $relative;
    + }
    + }
    +}
    +
    +foreach ($used as $key => $file) {
    + $isOwn = isset($languages['de'][$key]) || isset($languages['en'][$key]);
    + if (!$isOwn && \str_starts_with($key, 'wcf.')) {
    + // phrases of the core are preloaded by the core
    + continue;
    + }
    +
    + foreach ($languages as $languageCode => $items) {
    + if (!isset($items[$key])) {
    + $errors[] = "{$file}: the phrase {$key} is missing in language/{$languageCode}.xml";
    + }
    + }
    +
    + if (!isset($preloaded[$key])) {
    + $errors[] = "{$file}: the phrase {$key} is not preloaded (bootstrap or {jsphrase})";
    + }
    +}
    +
    +foreach ($dynamicKeys ? [] : \array_diff_key($preloaded, $used) as $key => $source) {
    + if (!isset($languages['de'][$key]) && !isset($languages['en'][$key])) {
    + // a phrase of the core, preloaded for a core module (e.g. the worker dialog)
    + continue;
    + }
    +
    + $errors[] = "{$source} preloads {$key}, but no TypeScript module uses it";
    +}
    +
    +finish($errors, \count($used) . ' phrases');
  • tests/lint/check-language.php
    <pre>@@ -0,0 +1,86 @@
    +<?php
    +
    +/**
    + * YoureCom Standard: checks the language files. Managed by _dev/standard (apply.php), do not edit here.
    + * - de.xml and en.xml define the same variables
    + * - German texts that address the reader have the informal and the formal variant
    + * - every statically used variable with one of the prefixes of tests/lint/config.php (`languagePrefixes`) exists
    + * - the texts use no `{@...}` output and compare strictly in `{if}`/`{elseif}` (like the templates)
    + * (whether the texts compile is checked against the dev system: check-devsystem.php)
    + *
    + * Usage: php tests/lint/check-language.php
    + */
    +
    +namespace yourecom\standard;
    +
    +require __DIR__ . '/lib.php';
    +
    +$config = config();
    +$de = languageKeys('de');
    +$en = languageKeys('en');
    +$errors = [];
    +
    +foreach (\array_diff($de, $en) as $key) {
    + $errors[] = "missing in en.xml: {$key}";
    +}
    +foreach (\array_diff($en, $de) as $key) {
    + $errors[] = "missing in de.xml: {$key}";
    +}
    +
    +\preg_match_all('~<item name="([^"]+)"><!\[CDATA\[(.*?)\]\]></item>~s', \is_file(ROOT . '/language/de.xml') ? (string)\file_get_contents(ROOT . '/language/de.xml') : '', $items, \PREG_SET_ORDER);
    +foreach ($items as $item) {
    + if (\str_contains($item[2], 'LANGUAGE_USE_INFORMAL_VARIANT')) {
    + continue;
    + }
    + if (\preg_match('~(?<![\w-])(du|dich|dir|dein\w*|Sie|Ihnen|Ihr\w*)(?![\w-])~u', $item[2], $address) === 1) {
    + $errors[] = "de.xml: {$item[1]} addresses the reader (\"{$address[1]}\") without the LANGUAGE_USE_INFORMAL_VARIANT check";
    + }
    +}
    +
    +foreach (['de', 'en'] as $languageCode) {
    + foreach (xml(ROOT . "/language/{$languageCode}.xml")?->query('//w:category/w:item') ?: [] as $item) {
    + if (!$item instanceof \DOMElement) {
    + continue;
    + }
    + $name = $item->getAttribute('name');
    + if (\str_contains($item->textContent, '{@')) {
    + $errors[] = "{$languageCode}.xml: {$name} uses {@...}, use {unsafe:...}";
    + }
    + foreach (looseConditions($item->textContent) as [, $problem]) {
    + $errors[] = "{$languageCode}.xml: {$name}: {$problem}";
    + }
    + }
    +}
    +
    +$prefixes = $config['languagePrefixes'];
    +if ($prefixes !== []) {
    + $known = \array_flip($de);
    + // names that are a prefix of a defined variable are used to build dynamic names
    + $partial = [];
    + foreach ($de as $key) {
    + $parts = \explode('.', $key);
    + for ($i = 1, $count = \count($parts); $i < $count; $i++) {
    + $partial[\implode('.', \array_slice($parts, 0, $i))] = true;
    + }
    + }
    +
    + $pattern = \implode('|', \array_map(static fn (string $prefix): string => \preg_quote($prefix, '~'), $prefixes));
    + $used = [];
    + foreach (files(['files', 'files_wcf', 'templates', 'acptemplates', 'ts'], ['.php', '.tpl', '.ts']) as $relative => $path) {
    + \preg_match_all("~['\"{}]((?:{$pattern})[A-Za-z0-9_.]*)['\"{]~", (string)\file_get_contents($path), $matches);
    + foreach ($matches[1] as $key) {
    + if (!\str_ends_with($key, '.')) {
    + $used[$key][$relative] = true;
    + }
    + }
    + }
    +
    + \ksort($used);
    + foreach ($used as $key => $files) {
    + if (!isset($known[$key]) && !isset($partial[$key]) && !\in_array($key, $config['languageIgnore'], true)) {
    + $errors[] = "unknown language variable {$key} (" . \implode(', ', \array_keys($files)) . ')';
    + }
    + }
    +}
    +
    +finish($errors, \count($de) . ' variables');
  • tests/lint/check-package.php
    <pre>@@ -0,0 +1,80 @@
    +<?php
    +
    +/**
    + * YoureCom Standard: checks package.xml and the generated files of the package. Managed by _dev/standard (apply.php),
    + * do not edit here.
    + * - every required package is listed once, com.woltlab.wcf is one of them
    + * - com.woltlab.wcf is excluded from the next minor version on (`<excludedpackage version="6.3.0 Alpha 1">` for a
    + * minversion of 6.2.x), the package is not installable on a version it was not tested with
    + * - the delete PIPs (fileDelete.xml, templateDelete.xml, acpTemplateDelete.xml) run in every update
    + * - constants.php matches option.xml (written by apply.php; `--fix` writes it here)
    + *
    + * Usage: php tests/lint/check-package.php [--fix]
    + */
    +
    +namespace yourecom\standard;
    +
    +require __DIR__ . '/lib.php';
    +
    +$fix = \in_array('--fix', $argv, true);
    +$errors = [];
    +
    +$package = xml(ROOT . '/package.xml');
    +if ($package === null) {
    + fail(['package.xml is missing or not well-formed']);
    +}
    +
    +$required = [];
    +foreach ($package->query('//w:requiredpackages/w:requiredpackage') ?: [] as $node) {
    + if (!$node instanceof \DOMElement) {
    + continue;
    + }
    + $name = \trim($node->textContent);
    + if (isset($required[$name])) {
    + $errors[] = "package.xml: {$name} is required twice";
    + }
    + $required[$name] = $node->getAttribute('minversion');
    +}
    +
    +if (!isset($required['com.woltlab.wcf'])) {
    + $errors[] = 'package.xml: com.woltlab.wcf is not a required package';
    +} elseif (\preg_match('~^(\d+)\.(\d+)\.~', $required['com.woltlab.wcf'], $version)) {
    + $expected = $version[1] . '.' . ((int)$version[2] + 1) . '.0 Alpha 1';
    + $excluded = null;
    + foreach ($package->query('//w:excludedpackages/w:excludedpackage') ?: [] as $node) {
    + if ($node instanceof \DOMElement && \trim($node->textContent) === 'com.woltlab.wcf') {
    + $excluded = $node->getAttribute('version');
    + }
    + }
    + if ($excluded !== $expected) {
    + $errors[] = "package.xml: <excludedpackage version=\"{$expected}\">com.woltlab.wcf</excludedpackage> is missing"
    + . ($excluded !== null ? " (found version=\"{$excluded}\")" : '');
    + }
    +} else {
    + $errors[] = "package.xml: com.woltlab.wcf has no minversion";
    +}
    +
    +$updates = $package->query('//w:instructions[@type="update"]') ?: [];
    +foreach (['fileDelete', 'templateDelete', 'acpTemplateDelete'] as $pip) {
    + if (!\is_file(ROOT . "/{$pip}.xml")) {
    + continue;
    + }
    + foreach ($updates as $update) {
    + if ($update instanceof \DOMElement && $package->query("w:instruction[@type=\"{$pip}\"]", $update)?->length === 0) {
    + $errors[] = "package.xml: the update from {$update->getAttribute('fromversion')} has no <instruction type=\"{$pip}\" />, {$pip}.xml is not applied";
    + }
    + }
    +}
    +
    +$constants = constantsSource(ROOT);
    +$current = \is_file(ROOT . '/constants.php') ? (string)\file_get_contents(ROOT . '/constants.php') : null;
    +if ($current === null || constantDefinitions($current) !== constantDefinitions($constants)) {
    + if ($fix) {
    + \file_put_contents(ROOT . '/constants.php', $constants);
    + echo "constants.php written\n";
    + } else {
    + $errors[] = 'constants.php does not match option.xml, run: php tests/lint/check-package.php --fix';
    + }
    +}
    +
    +finish($errors, \count($required) . ' required packages');
  • tests/lint/check-php.php
    <pre>@@ -0,0 +1,157 @@
    +<?php
    +
    +/**
    + * YoureCom Standard: rules for the PHP code of the package. Managed by _dev/standard (apply.php), do not edit here.
    + * - no debug output (`wcfDebug()`, `var_dump()`, `dump()`, `dd()`)
    + * - no `WCF_N`, the database layer replaces `wcf1_` itself
    + * - `json_decode()` with `JSON_THROW_ON_ERROR` (or `JSON::decode()`), a broken document must not become null silently
    + * - `StringUtil::trim()` instead of the native `trim()` (it also removes Unicode whitespace); `trim()` with an own
    + * character list stays allowed
    + * - classes, interfaces, traits and enums have a docblock with a description, `@author` and `@license`
    + *
    + * Methods (`#[\Override]` or a docblock) and strict comparisons are checked by PHPStan (phpstan.standard.neon).
    + *
    + * Usage: php tests/lint/check-php.php
    + */
    +
    +namespace yourecom\standard;
    +
    +require __DIR__ . '/lib.php';
    +
    +$errors = [];
    +$count = 0;
    +
    +foreach (files(['files', 'files_wcf'], ['.php']) as $relative => $path) {
    + $source = (string)\file_get_contents($path);
    + $count++;
    +
    + foreach (functionCalls($source, ['wcfdebug', 'var_dump', 'dump', 'dd', 'json_decode', 'trim']) as $call) {
    + if ($call['name'] === 'trim') {
    + if (argumentCount($call['arguments']) === 1) {
    + $errors[] = "{$relative}:{$call['line']}: native trim(), use StringUtil::trim()";
    + }
    + } elseif ($call['name'] !== 'json_decode') {
    + $errors[] = "{$relative}:{$call['line']}: debug output {$call['name']}()";
    + } elseif (!\str_contains($call['arguments'], 'JSON_THROW_ON_ERROR')) {
    + $errors[] = "{$relative}:{$call['line']}: json_decode() without JSON_THROW_ON_ERROR (or use JSON::decode())";
    + }
    + }
    +
    + $tokens = \token_get_all($source);
    + foreach ($tokens as $index => $token) {
    + if (!\is_array($token)) {
    + continue;
    + }
    +
    + if (\in_array($token[0], [\T_STRING, \T_NAME_FULLY_QUALIFIED], true) && \ltrim($token[1], '\\') === 'WCF_N') {
    + $errors[] = "{$relative}:{$token[2]}: WCF_N, write wcf1_ (the database layer replaces the prefix)";
    + continue;
    + }
    +
    + if (!\in_array($token[0], [\T_CLASS, \T_INTERFACE, \T_TRAIT, \T_ENUM], true)) {
    + continue;
    + }
    + // `Foo::class`, `new class` and enum used as an identifier are no declarations
    + $previous = nextToken($tokens, $index, -1);
    + $next = nextToken($tokens, $index, 1);
    + if ($next === null || !\is_array($tokens[$next]) || $tokens[$next][0] !== \T_STRING) {
    + continue;
    + }
    + if ($previous !== null && \is_array($tokens[$previous]) && \in_array($tokens[$previous][0], [\T_DOUBLE_COLON, \T_NEW], true)) {
    + continue;
    + }
    +
    + $name = $tokens[$next][1];
    + $docComment = classDocComment($tokens, $index);
    + if ($docComment === null) {
    + $errors[] = "{$relative}:{$token[2]}: {$name} has no docblock (description, @author, @license)";
    + continue;
    + }
    +
    + $missing = [];
    + if (!\preg_match('~^\s*\*\s*[^@\s*/]~m', $docComment)) {
    + $missing[] = 'a description';
    + }
    + foreach (['@author', '@license'] as $tag) {
    + if (!\str_contains($docComment, $tag)) {
    + $missing[] = $tag;
    + }
    + }
    + if ($missing !== []) {
    + $errors[] = "{$relative}:{$token[2]}: the docblock of {$name} lacks " . \implode(', ', $missing);
    + }
    + }
    +}
    +
    +finish($errors, "{$count} files");
    +
    +/**
    + * Returns the number of arguments of a call, given the source of its parenthesized argument list.
    + */
    +function argumentCount(string $arguments): int
    +{
    + $depth = 0;
    + $count = 0;
    + $hasContent = false;
    + foreach (\token_get_all('<?php ' . $arguments) as $token) {
    + if (\is_array($token) && \in_array($token[0], [\T_OPEN_TAG, \T_WHITESPACE, \T_COMMENT, \T_DOC_COMMENT], true)) {
    + continue;
    + }
    + $text = \is_array($token) ? $token[1] : $token;
    + if ($text === '(' || $text === '[' || $text === '{') {
    + $depth++;
    + if ($depth === 1) {
    + continue;
    + }
    + } elseif ($text === ')' || $text === ']' || $text === '}') {
    + $depth--;
    + } elseif ($text === ',' && $depth === 1) {
    + $count++;
    + $hasContent = false;
    + continue;
    + }
    + if ($depth >= 1) {
    + $hasContent = true;
    + }
    + }
    +
    + // a trailing comma does not start another argument
    + return $count + ($hasContent ? 1 : 0);
    +}
    +
    +/**
    + * Returns the docblock in front of a class declaration (before its modifiers and attributes), null if there is none.
    + *
    + * @param array<int, array{0: int, 1: string, 2: int}|string> $tokens
    + */
    +function classDocComment(array $tokens, int $index): ?string
    +{
    + $depth = 0;
    + for ($i = $index - 1; $i >= 0; $i--) {
    + $token = $tokens[$i];
    + $text = \is_array($token) ? $token[1] : $token;
    +
    + // skip attributes: #[ ... ]
    + if ($text === ']') {
    + $depth++;
    + continue;
    + }
    + if ($depth > 0) {
    + if ($text === '[' || (\is_array($token) && $token[0] === \T_ATTRIBUTE)) {
    + $depth--;
    + }
    + continue;
    + }
    +
    + if (\is_array($token) && $token[0] === \T_DOC_COMMENT) {
    + return $token[1];
    + }
    + if (\is_array($token) && \in_array($token[0], [\T_WHITESPACE, \T_COMMENT, \T_FINAL, \T_ABSTRACT, \T_READONLY], true)) {
    + continue;
    + }
    +
    + return null;
    + }
    +
    + return null;
    +}
  • tests/lint/check-templates.php
    <pre>@@ -0,0 +1,142 @@
    +<?php
    +
    +/**
    + * YoureCom Standard: rules for templates and the frontend. Managed by _dev/standard (apply.php), do not edit here.
    + * - no jQuery in templates and TypeScript
    + * - no legacy AJAX actions (`Ajax.dboAction`, `WCF.Action.Proxy`) in TypeScript, new server calls are RPC endpoints
    + * - no compiled .js files next to the TypeScript sources
    + * - no `{@...}` output (WoltLab 6.1+: `{unsafe:...}` makes unescaped output explicit)
    + * - strict comparisons in `{if}`/`{elseif}`: `===`/`!==` instead of `==`/`!=`, no `|empty`/`empty()`
    + * (compare with `=== []`, `=== ''`, `=== 0` or `=== null`)
    + * - no debug output (`|wcfDebug`) and no legacy phrases (`Language.addObject(`, phrases come from `{jsphrase}`)
    + * - HTML: no duplicate attributes in a tag, no duplicate static ids in a template, `<button>` with a `type`,
    + * form fields with a label (`<label for>`, an enclosing `<label>`, `aria-label`, `aria-labelledby` or `title`;
    + * fields with a dynamic id are labelled by the including template)
    + *
    + * Usage: php tests/lint/check-templates.php
    + */
    +
    +namespace yourecom\standard;
    +
    +require __DIR__ . '/lib.php';
    +
    +$errors = [];
    +
    +foreach (files(templateDirectories(), ['.tpl']) as $relative => $path) {
    + // comments do not count, their line breaks stay for the line numbers
    + $source = (string)\preg_replace_callback('~\{\*.*?\*\}~s', static fn (array $match): string => \str_repeat("\n", \substr_count($match[0], "\n")), (string)\file_get_contents($path));
    + $line = static fn (int $offset): int => \substr_count($source, "\n", 0, $offset) + 1;
    +
    + foreach (\explode("\n", $source) as $index => $text) {
    + $number = $index + 1;
    + if (\preg_match('~\bjQuery\b|\$\(\s*[\'"#.]~', $text)) {
    + $errors[] = "{$relative}:{$number}: jQuery";
    + }
    + if (\preg_match('~\{@~', $text)) {
    + $errors[] = "{$relative}:{$number}: {@...} is deprecated, use {unsafe:...}";
    + }
    + if (\preg_match('~\|wcfDebug\b~', $text)) {
    + $errors[] = "{$relative}:{$number}: debug output |wcfDebug";
    + }
    + if (\str_contains($text, 'Language.addObject(')) {
    + $errors[] = "{$relative}:{$number}: Language.addObject(), preload the phrases with {jsphrase}";
    + }
    + }
    +
    + foreach (looseConditions($source) as [$offset, $problem]) {
    + $errors[] = "{$relative}:{$line($offset)}: {$problem}";
    + }
    +
    + // the HTML tags, without Smarty tags that contain a ">" (conditions)
    + \preg_match_all('~<([a-zA-Z][\w-]*)\b((?:[^<>{]|\{[^}]*\})*)>~', $source, $tags, \PREG_SET_ORDER | \PREG_OFFSET_CAPTURE);
    + $ids = [];
    + $labelFor = [];
    + \preg_match_all('~\bfor="([^"]+)"~', $source, $labels);
    + foreach ($labels[1] as $for) {
    + $labelFor[$for] = true;
    + }
    +
    + foreach ($tags as $tag) {
    + $name = \strtolower($tag[1][0]);
    + $attributes = $tag[2][0];
    + $offset = $tag[0][1];
    + $number = $line($offset);
    + // attributes that depend on a condition can exist twice ({if}a="1"{else}a="2"{/if})
    + $conditional = \preg_match('~\{(?:if|else|elseif)\b~', $attributes) === 1;
    + $static = (string)\preg_replace('~\{[^}]*\}~', '', $attributes);
    +
    + \preg_match_all('~(?:^|\s)([a-zA-Z_:][-\w:.]*)(?=\s*=)~', $static, $names);
    + if (!$conditional) {
    + $duplicates = \array_unique(\array_diff_assoc(\array_map('strtolower', $names[1]), \array_unique(\array_map('strtolower', $names[1]))));
    + if ($duplicates !== []) {
    + $errors[] = "{$relative}:{$number}: <{$name}> has the attribute " . \implode(', ', $duplicates) . ' twice';
    + }
    + }
    +
    + if (\preg_match('~(?:^|\s)id="([A-Za-z][\w:.-]*)"~', $attributes, $id)) {
    + $ids[$id[1]][] = $offset;
    + }
    +
    + if ($name === 'button' && !\preg_match('~(?:^|\s)type\s*=~', $attributes)) {
    + $errors[] = "{$relative}:{$number}: <button> without type (the default is submit)";
    + }
    +
    + if (\in_array($name, ['input', 'select', 'textarea'], true) && !hasLabel($name, $attributes, $source, $offset, $labelFor)) {
    + $errors[] = "{$relative}:{$number}: <{$name}> without a label (<label for>, enclosing <label>, aria-label, aria-labelledby or title)";
    + }
    + }
    +
    + foreach ($ids as $id => $offsets) {
    + for ($i = 1, $count = \count($offsets); $i < $count; $i++) {
    + // the same id in the branches of a condition is fine
    + $between = \substr($source, $offsets[$i - 1], $offsets[$i] - $offsets[$i - 1]);
    + if (!\preg_match('~\{(?:else|elseif)\b~', $between)) {
    + $errors[] = "{$relative}:{$line($offsets[$i])}: the id \"{$id}\" exists twice in the template";
    + }
    + }
    + }
    +}
    +
    +foreach (files(['ts'], ['.ts', '.js']) as $relative => $path) {
    + if (\str_ends_with($relative, '.js')) {
    + $errors[] = "{$relative}: compiled JavaScript next to the TypeScript sources";
    + continue;
    + }
    +
    + foreach (\file($path) ?: [] as $index => $text) {
    + $number = $index + 1;
    + if (\preg_match('~\bjQuery\b|from "jquery"~', $text)) {
    + $errors[] = "{$relative}:{$number}: jQuery";
    + }
    + if (\preg_match('~\bdboAction\(|WCF\.Action\.Proxy~', $text)) {
    + $errors[] = "{$relative}:{$number}: legacy AJAX action, use an RPC endpoint";
    + }
    + }
    +}
    +
    +finish($errors);
    +
    +/**
    + * Tells whether a form field has an accessible label.
    + *
    + * @param array<string, true> $labelFor the values of all for="..." attributes of the template
    + */
    +function hasLabel(string $name, string $attributes, string $source, int $offset, array $labelFor): bool
    +{
    + if ($name === 'input' && \preg_match('~(?:^|\s)type="(?:hidden|submit|button|reset|image|\{[^"]*)"~i', $attributes)) {
    + return true;
    + }
    + if (\preg_match('~(?:^|\s)(?:aria-label|aria-labelledby|title)\s*=~', $attributes)) {
    + return true;
    + }
    + // a dynamic id is labelled by the including template (e.g. option types inside the <label for> of the core)
    + if (\preg_match('~(?:^|\s)id="([^"]+)"~', $attributes, $id) && (isset($labelFor[$id[1]]) || \str_contains($id[1], '{'))) {
    + return true;
    + }
    +
    + // inside a <label> that is not closed before the field
    + $before = \substr($source, 0, $offset);
    + $open = \strrpos($before, '<label');
    +
    + return $open !== false && !\str_contains(\substr($before, $open), '</label>');
    +}
  • tests/lint/check-xsd.php
    <pre>@@ -0,0 +1,107 @@
    +<?php
    +
    +/**
    + * YoureCom Standard: validates the PIP and language files against the XSDs of WoltLab Suite 6.2.
    + * Managed by _dev/standard (apply.php), do not edit here.
    + *
    + * Every file has to reference https://www.woltlab.com/XSD/6.0/<pip>.xsd; own PIPs of the YoureCom plugins reference
    + * https://www.yourecom.de/XSD/6.2/<pip>.xsd (files/XSD/6.2 of de.yourecom.website, environment variable YOURECOM_XSD).
    + * The XSDs of WoltLab come from the WCF repository
    + * (directory `XSD`): environment variable WCF_XSD, locally default `../var/wcf/XSD` (cloned by _dev/standard/check.sh).
    + * Gaps of the XSDs (elements the core accepts but the XSD does not know) are listed in tests/lint/config.php
    + * (`xsdKnown`); entries that no longer match make the check fail, so the list can only shrink.
    + *
    + * Usage: php tests/lint/check-xsd.php
    + */
    +
    +namespace yourecom\standard;
    +
    +require __DIR__ . '/lib.php';
    +
    +$xsdDirectory = \getenv('WCF_XSD');
    +if ($xsdDirectory === false || $xsdDirectory === '') {
    + $xsdDirectory = ROOT . '/../var/wcf/XSD';
    +}
    +if (!\is_dir($xsdDirectory)) {
    + fail(["The XSD directory does not exist: {$xsdDirectory} (set WCF_XSD)"]);
    +}
    +
    +// XSDs of the own PIPs of the YoureCom plugins: environment variable YOURECOM_XSD, locally the folder of the website plugin
    +$yourecomXsdDirectory = \getenv('YOURECOM_XSD');
    +if ($yourecomXsdDirectory === false || $yourecomXsdDirectory === '') {
    + $yourecomXsdDirectory = ROOT . '/../de.yourecom.website/files/XSD/6.2';
    +}
    +
    +// gaps of the XSDs of the WCF repository: the core accepts and uses these elements and attributes in its own packages
    +const CORE_GAPS = [
    + 'objectType.xml' => ['defaultpermission', '}page'],
    + 'package.xml' => ["'languagecode'"],
    + 'templateListener.xml' => ['templatecode'],
    + 'userGroupOption.xml' => ["is not a valid value of the atomic type 'xs:unsignedInt'"],
    +];
    +
    +$known = config()['xsdKnown'];
    +$matched = [];
    +$errors = [];
    +$checked = 0;
    +
    +\libxml_use_internal_errors(true);
    +foreach (\array_merge(\glob(ROOT . '/*.xml') ?: [], \glob(ROOT . '/language/*.xml') ?: []) as $file) {
    + $name = \basename($file);
    + if (\str_starts_with($name, 'phpunit')) {
    + continue;
    + }
    +
    + $document = new \DOMDocument();
    + if (!$document->load($file)) {
    + $errors[] = "{$name}: not well-formed";
    + continue;
    + }
    +
    + $schemaLocation = $document->documentElement?->getAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'schemaLocation') ?? '';
    + if (\preg_match('~^http://www\.woltlab\.com\s+https://www\.yourecom\.de/XSD/6\.2/(\w+\.xsd)$~', \trim($schemaLocation), $match)) {
    + // PIPs of the YoureCom plugins have no WoltLab XSD, theirs live in de.yourecom.website (files/XSD/6.2)
    + $schema = $yourecomXsdDirectory . '/' . $match[1];
    + } elseif (\preg_match('~^http://www\.woltlab\.com\s+https://www\.woltlab\.com/XSD/6\.0/(\w+\.xsd)$~', \trim($schemaLocation), $match)) {
    + $schema = $xsdDirectory . '/' . $match[1];
    + } else {
    + $errors[] = "{$name}: xsi:schemaLocation must be \"http://www.woltlab.com https://www.woltlab.com/XSD/6.0/<pip>.xsd\" (own PIPs: \"http://www.woltlab.com https://www.yourecom.de/XSD/6.2/<pip>.xsd\")";
    + continue;
    + }
    + if (!\is_file($schema)) {
    + $errors[] = "{$name}: schema {$match[1]} not found";
    + continue;
    + }
    +
    + $checked++;
    + \libxml_clear_errors();
    + if ($document->schemaValidate($schema)) {
    + continue;
    + }
    +
    + foreach (\libxml_get_errors() as $error) {
    + $message = \trim($error->message);
    + foreach (CORE_GAPS[$name] ?? [] as $part) {
    + if (\str_contains($message, $part)) {
    + continue 2;
    + }
    + }
    + foreach ($known[$name] ?? [] as $part) {
    + if (\str_contains($message, $part)) {
    + $matched[$name][$part] = true;
    + continue 2;
    + }
    + }
    + $errors[] = \sprintf('%s:%d: %s', $name, $error->line, $message);
    + }
    +}
    +
    +foreach ($known as $name => $parts) {
    + foreach ($parts as $part) {
    + if (!isset($matched[$name][$part])) {
    + $errors[] = "{$name}: the known finding '{$part}' does not occur any more, remove it from tests/lint/config.php";
    + }
    + }
    +}
    +
    +finish($errors, "{$checked} files");
  • tests/lint/config.php
    <pre>@@ -0,0 +1,17 @@
    +<?php
    +
    +/**
    + * Plugin specific settings of the YoureCom Standard checks (tests/lint/*).
    + */
    +return [
    + // statically used language variables with these prefixes must exist in language/*.xml
    + 'languagePrefixes' => [],
    + // variables with these prefixes that are defined elsewhere (keep this list short)
    + 'languageIgnore' => [],
    + // gaps of the WoltLab XSDs: file name => parts of the validation message (the list can only shrink)
    + 'xsdKnown' => [],
    + // legacy patterns that cannot be changed yet: relative file => rules of check-architecture.php (the list can only shrink)
    + 'architectureKnown' => [],
    + // committed build of ts/ (the outDir of tsconfig.test.json), null without TypeScript
    + 'jsBuildDir' => null,
    +];
  • tests/lint/lib.php
    <pre>@@ -0,0 +1,407 @@
    +<?php
    +
    +/**
    + * YoureCom Standard: shared helpers of the lint scripts. Managed by _dev/standard (apply.php), do not edit here.
    + *
    + * Reads the package name from package.xml and the plugin specific settings from tests/lint/config.php.
    + */
    +
    +namespace yourecom\standard;
    +
    +const ROOT = __DIR__ . '/../..';
    +
    +/**
    + * Returns the plugin specific settings merged with the defaults.
    + *
    + * @return array{
    + * languagePrefixes: list<string>,
    + * languageIgnore: list<string>,
    + * xsdKnown: array<string, list<string>>,
    + * architectureKnown: array<string, list<string>>,
    + * jsBuildDir: string|null,
    + * jsPhrasesDynamic: bool,
    + * }
    + */
    +function config(): array
    +{
    + $file = ROOT . '/tests/lint/config.php';
    + $config = \is_file($file) ? require $file : [];
    +
    + return [
    + 'languagePrefixes' => $config['languagePrefixes'] ?? [],
    + 'languageIgnore' => $config['languageIgnore'] ?? [],
    + 'xsdKnown' => $config['xsdKnown'] ?? [],
    + 'architectureKnown' => $config['architectureKnown'] ?? [],
    + 'jsBuildDir' => $config['jsBuildDir'] ?? null,
    + 'jsPhrasesDynamic' => $config['jsPhrasesDynamic'] ?? false,
    + ];
    +}
    +
    +/**
    + * Returns the package identifier from package.xml.
    + */
    +function packageName(): string
    +{
    + $source = (string)\file_get_contents(ROOT . '/package.xml');
    + if (!\preg_match('~<package name="([^"]+)"~', $source, $match)) {
    + fail(['package.xml has no package name']);
    + }
    +
    + return $match[1];
    +}
    +
    +/**
    + * Returns the source of the bootstrap file of the package (empty if there is none).
    + */
    +function bootstrap(): string
    +{
    + $name = packageName();
    + foreach (['files', 'files_wcf'] as $directory) {
    + foreach (\glob(ROOT . "/{$directory}/lib/bootstrap/*.php") ?: [] as $file) {
    + if (\strcasecmp(\basename($file, '.php'), $name) === 0) {
    + return (string)\file_get_contents($file);
    + }
    + }
    + }
    +
    + return '';
    +}
    +
    +/**
    + * Returns the item names of a language file.
    + *
    + * @return list<string>
    + */
    +function languageKeys(string $languageCode): array
    +{
    + $file = ROOT . "/language/{$languageCode}.xml";
    + if (!\is_file($file)) {
    + return [];
    + }
    +
    + $source = (string)\file_get_contents($file);
    + \preg_match_all('~<item name="([^"]+)"~', $source, $matches);
    +
    + return $matches[1];
    +}
    +
    +/**
    + * Returns the directories with templates: `templates/` and `acptemplates/` (template PIPs) and templates an application
    + * ships as files (`files/templates/`, `files/acp/templates/`).
    + *
    + * @return list<string>
    + */
    +function templateDirectories(): array
    +{
    + return ['templates', 'acptemplates', 'files/templates', 'files/acp/templates'];
    +}
    +
    +/**
    + * Yields the files below the given directories of the plugin whose name ends with one of the suffixes. Bundled
    + * third-party code (`vendor/` directories) is skipped, like PHP-CS-Fixer does.
    + *
    + * @param list<string> $directories
    + * @param list<string> $suffixes
    + * @return \Generator<string, string> relative path => absolute path
    + */
    +function files(array $directories, array $suffixes): \Generator
    +{
    + $root = \str_replace('\\', '/', (string)\realpath(ROOT));
    + foreach ($directories as $directory) {
    + if (!\is_dir(ROOT . '/' . $directory)) {
    + continue;
    + }
    +
    + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(ROOT . '/' . $directory, \FilesystemIterator::SKIP_DOTS));
    + foreach ($iterator as $file) {
    + $path = \str_replace('\\', '/', (string)\realpath($file->getPathname()));
    + if (\str_contains(\substr($path, \strlen($root)), '/vendor/')) {
    + continue;
    + }
    + foreach ($suffixes as $suffix) {
    + if (\str_ends_with($path, $suffix)) {
    + yield \substr($path, \strlen($root) + 1) => $path;
    + break;
    + }
    + }
    + }
    + }
    +}
    +
    +/**
    + * Returns the calls of the given global functions in a PHP source (not methods, not declarations).
    + *
    + * @param list<string> $names lower case function names
    + * @return list<array{name: string, line: int, arguments: string}>
    + */
    +function functionCalls(string $source, array $names): array
    +{
    + $tokens = \token_get_all($source);
    + $calls = [];
    + foreach ($tokens as $index => $token) {
    + if (!\is_array($token) || !\in_array($token[0], [\T_STRING, \T_NAME_FULLY_QUALIFIED], true)) {
    + continue;
    + }
    + $name = \strtolower(\ltrim($token[1], '\\'));
    + if (!\in_array($name, $names, true)) {
    + continue;
    + }
    +
    + $next = nextToken($tokens, $index, 1);
    + $previous = nextToken($tokens, $index, -1);
    + if ($next === null || $tokens[$next] !== '(') {
    + continue;
    + }
    + if ($previous !== null && \is_array($tokens[$previous])
    + && \in_array($tokens[$previous][0], [\T_OBJECT_OPERATOR, \T_NULLSAFE_OBJECT_OPERATOR, \T_DOUBLE_COLON, \T_FUNCTION, \T_NEW, \T_CONST], true)) {
    + continue;
    + }
    +
    + // the source of the arguments up to the matching parenthesis
    + $depth = 0;
    + $arguments = '';
    + for ($i = $next, $count = \count($tokens); $i < $count; $i++) {
    + $text = \is_array($tokens[$i]) ? $tokens[$i][1] : $tokens[$i];
    + if ($text === '(' || $text === '[') {
    + $depth++;
    + } elseif ($text === ')' || $text === ']') {
    + $depth--;
    + }
    + $arguments .= $text;
    + if ($depth === 0) {
    + break;
    + }
    + }
    +
    + $calls[] = ['name' => $name, 'line' => $token[2], 'arguments' => $arguments];
    + }
    +
    + return $calls;
    +}
    +
    +/**
    + * Returns the index of the next (direction 1) or previous (-1) token that is no whitespace or comment.
    + *
    + * @param array<int, array{0: int, 1: string, 2: int}|string> $tokens
    + */
    +function nextToken(array $tokens, int $index, int $direction): ?int
    +{
    + for ($i = $index + $direction; isset($tokens[$i]); $i += $direction) {
    + if (!\is_array($tokens[$i]) || !\in_array($tokens[$i][0], [\T_WHITESPACE, \T_COMMENT, \T_DOC_COMMENT], true)) {
    + return $i;
    + }
    + }
    +
    + return null;
    +}
    +
    +/**
    + * Loads an XML file of the plugin, null if it does not exist or is not well-formed.
    + */
    +function xml(string $file): ?\DOMXPath
    +{
    + if (!\is_file($file)) {
    + return null;
    + }
    +
    + $document = new \DOMDocument();
    + $previous = \libxml_use_internal_errors(true);
    + $loaded = $document->load($file);
    + \libxml_clear_errors();
    + \libxml_use_internal_errors($previous);
    + if (!$loaded) {
    + return null;
    + }
    +
    + $xpath = new \DOMXPath($document);
    + $xpath->registerNamespace('w', 'http://www.woltlab.com');
    +
    + return $xpath;
    +}
    +
    +/**
    + * Returns the application each source directory of the plugin is installed into (files, files_wcf, templates,
    + * acptemplates): the application attribute of the install instruction, else the package itself for applications,
    + * else wcf.
    + *
    + * @return array<string, string>
    + */
    +function applications(string $pluginDir = ROOT): array
    +{
    + $xpath = xml($pluginDir . '/package.xml');
    + $own = 'wcf';
    + if ($xpath !== null && \trim((string)$xpath->evaluate('string(//w:packageinformation/w:isapplication)')) === '1') {
    + $name = (string)$xpath->evaluate('string(/w:package/@name)');
    + $own = \strtolower(\substr($name, (int)\strrpos($name, '.') + 1));
    + }
    +
    + $applications = ['files' => $own, 'files_wcf' => 'wcf', 'templates' => $own, 'acptemplates' => $own];
    + $types = ['file' => 'files', 'template' => 'templates', 'acpTemplate' => 'acptemplates'];
    + foreach ($xpath?->query('//w:instructions[@type="install"]/w:instruction') ?: [] as $instruction) {
    + if (!$instruction instanceof \DOMElement || !isset($types[$instruction->getAttribute('type')])) {
    + continue;
    + }
    + $archive = \trim($instruction->textContent);
    + $directory = $archive !== '' ? \basename($archive, '.tar') : $types[$instruction->getAttribute('type')];
    + if ($instruction->getAttribute('application') !== '' && isset($applications[$directory])) {
    + $applications[$directory] = $instruction->getAttribute('application');
    + }
    + }
    +
    + return $applications;
    +}
    +
    +/**
    + * Builds constants.php of a plugin: the constants of its options (option.xml), of the application and of the
    + * framework, so PHPStan and the IDE know them. Written by apply.php, checked by check-package.php.
    + * The option constants have the types of the core's options.inc.php (OptionEditor::rebuild()): boolean and integer
    + * options are int, all others string.
    + */
    +function constantsSource(string $pluginDir): string
    +{
    + $pluginDir = \str_replace('\\', '/', (string)\realpath($pluginDir));
    + $groups = ['0' => [], "''" => []];
    + $options = xml($pluginDir . '/option.xml');
    + foreach ($options?->query('//w:import/w:options/w:option') ?: [] as $option) {
    + if (!$option instanceof \DOMElement || $option->getAttribute('name') === '') {
    + continue;
    + }
    + $type = \strtolower(\trim((string)$options->evaluate('string(w:optiontype)', $option)));
    + $value = \in_array($type, ['boolean', 'integer'], true) ? '0' : "''";
    + $groups[$value][\strtoupper($option->getAttribute('name'))] = $value;
    + }
    +
    + $lines = [
    + '<?php',
    + '/**',
    + ' * Defines constants for autocompletion in IDEs. This file is not meant to be actively used anywhere!',
    + ' *',
    + ' * @author Marcel Beckers',
    + ' * @license YoureCom License - Commercial (YCLC) <https://www.yourecom.de/shop/licence-commercial/>',
    + ' * @package ' . \basename($pluginDir),
    + ' */',
    + '',
    + '// option constants',
    + '',
    + '// application constants',
    + ];
    + $package = xml($pluginDir . '/package.xml');
    + if ($package !== null && \trim((string)$package->evaluate('string(//w:packageinformation/w:isapplication)')) === '1') {
    + $name = (string)$package->evaluate('string(/w:package/@name)');
    + if (\preg_match('~^[^.]+\.[^.]+\.(.+)$~', $name, $match)) {
    + $key = \strtoupper($match[1]);
    + $lines[] = "define('{$key}_DIR', '');";
    + $lines[] = "define('RELATIVE_{$key}_DIR', '');";
    + }
    + }
    + \array_push(
    + $lines,
    + '',
    + '// hardcoded framework constants',
    + "define('WCF_DIR', '');",
    + "define('LEGAL_NOTICE_EMAIL_ADDRESS', '');",
    + "define('DOMPDF_LOG_FILE', WCF_DIR . 'tmp/dompdf.log');",
    + );
    + foreach ($groups as $constants) {
    + \ksort($constants, \SORT_STRING);
    + foreach ($constants as $constant => $value) {
    + $lines[] = "define('{$constant}', {$value});";
    + }
    + }
    +
    + return \implode("\n", $lines) . "\n";
    +}
    +
    +/**
    + * Returns the define() lines of a constants.php source, sorted, to compare two versions independent of the order.
    + *
    + * @return list<string>
    + */
    +function constantDefinitions(string $source): array
    +{
    + \preg_match_all('~^define\(.*$~m', \str_replace("\r", '', $source), $matches);
    + $definitions = \array_map('trim', $matches[0]);
    + \sort($definitions);
    +
    + return $definitions;
    +}
    +
    +/**
    + * Returns the loose comparisons in the conditions ({if}, {elseif}) of a template or language item:
    + * list of [offset, problem].
    + *
    + * @return list<array{int, string}>
    + */
    +function looseConditions(string $source): array
    +{
    + $problems = [];
    + \preg_match_all('~\{(?:if|elseif)\s[^}]*\}~', $source, $conditions, \PREG_OFFSET_CAPTURE);
    + foreach ($conditions[0] as [$condition, $offset]) {
    + // strings in the condition do not count
    + $code = (string)\preg_replace('~\'(?:[^\'\\\\]|\\\\.)*\'|"(?:[^"\\\\]|\\\\.)*"~', "''", $condition);
    + if (\preg_match('~(?<![=!<>])(==|!=|<>)(?!=)~', $code, $operator)) {
    + $strict = $operator[1] === '==' ? '===' : '!==';
    + $problems[] = [$offset, "loose comparison {$operator[1]} in {$condition}, use {$strict}"];
    + }
    + if (\preg_match('~\|empty\b|\bempty\s*\(~', $code)) {
    + $problems[] = [$offset, "empty in {$condition}, compare strictly (=== [], === '', === 0, === null)"];
    + }
    + }
    +
    + return $problems;
    +}
    +
    +/**
    + * Runs git in the plugin directory, null if git fails (no repository).
    + *
    + * @param list<string> $arguments
    + */
    +function git(array $arguments): ?string
    +{
    + // without a shell: escapeshellarg() on Windows replaces "%" (e.g. in --format=%H); the answer goes through a
    + // file, pipes are slow on Windows
    + $answer = (string)\tempnam(\sys_get_temp_dir(), 'yourecom-git-');
    + $process = \proc_open(
    + ['git', '-C', (string)\realpath(ROOT), ...$arguments],
    + [1 => ['file', $answer, 'w'], 2 => ['file', \PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'w']],
    + $pipes
    + );
    + if (!\is_resource($process)) {
    + \unlink($answer);
    +
    + return null;
    + }
    + $status = \proc_close($process);
    + $output = (string)\file_get_contents($answer);
    + \unlink($answer);
    +
    + return $status === 0 ? \rtrim(\str_replace("\r\n", "\n", $output), "\n") : null;
    +}
    +
    +/**
    + * Prints the errors and exits with status 1, prints "OK" otherwise.
    + *
    + * @param list<string> $errors
    + */
    +function finish(array $errors, string $summary = ''): never
    +{
    + if ($errors !== []) {
    + fail($errors);
    + }
    +
    + echo 'OK' . ($summary !== '' ? " ({$summary})" : '') . "\n";
    +
    + exit(0);
    +}
    +
    +/**
    + * Prints the errors and exits with status 1.
    + *
    + * @param list<string> $errors
    + */
    +function fail(array $errors): never
    +{
    + \fwrite(\STDERR, \implode("\n", $errors) . "\n");
    +
    + exit(1);
    +}
Es wurden keine Einträge gefunden.

Bereit, deine Community smarter zu machen?

Ob fertiges Produkt oder individuelle Softwarelösung – überzeuge dich selbst.

Jetzt kaufen Demo testen

Direkter Support vom Entwickler

Zuletzt erledigte Aufgaben

  1. Multihunter meldet jede Anmeldung als Doppelaccount Multihunter (Doppelaccount Jäger) Multihunter: Forum Benachrichtigungen

    villain Erledigt am 27. Oktober 2026 um 20:09
  2. Multihunter meldet jede Anmeldung als Doppelaccount Multihunter (Doppelaccount Jäger)

    villain Erledigt am 26. Oktober 2026 um 14:41
  3. Optionen vom "Dateien Feld-Typ" sprechen von Bildern Database: Medienfelder

    doerek Erledigt am 25. Oktober 2026 um 18:20
  4. Nach Installation ohne Popup Begriffserklärung

    mbbaureihen.de Erledigt am 24. Oktober 2026 um 23:33
  5. Fehlermeldung beim Hinzufügen einer neuen Clanseite Clan Verwaltung

    BMWTom Erledigt am 24. Oktober 2026 um 23:16
  6. neue Dateiendungen werden nicht übernommen. Database: Medienfelder

    doerek Erledigt am 24. Oktober 2026 um 19:46
Arbeitsplatz
YoureCom – Software für Vereine, Teams & Communities

YoureCom – Software für Vereine, Teams & Communities. Seit 2004.

Fertig eingerichtete Lösungen für Verein, Team und Community, dazu Erweiterungen für die WoltLab Suite. Aus Wuppertal, für Kunden weltweit.

Lösungen

  • Tipprunde
  • Aufgaben & Support
  • Mitglieder-Netzwerk
  • Online-Shop
  • Alle Lösungen & Preise

Für Administratoren

  • Plugins
  • Clan Verwaltung
  • Wettbewerbe
  • MultiHunter
  • ByteMon
  • Individuelle Entwicklung

YoureCom

  • Über mich
  • Shop
  • Kontakt
  • Neuigkeiten
  • GitHub

Cloud-Verträge

  • AGB Cloud
  • AVV Cloud
  • Rechtswidrige Inhalte melden
  • Datenwechsel

Rechtliches

  • Datenschutzerklärung
  • Impressum
  • AGB
  • Vertrag widerrufen
  • Abonnements kündigen
  • Widerrufsbelehrung
© 2026 YoureCom · Marcel Beckers