Developer 

Architecture 

  • All classes are registered through Configuration/Services.yaml with autowiring and use constructor injection.
  • The page module panel and the file list button are PSR-14 event listeners.
  • The bulk image metadata command is a Symfony console command, registered with schedulable: true.
  • The JSON-LD output is a PSR-15 frontend middleware.
  • Backend actions are backend routes. The Content Quality module is an Extbase backend module.
  • The rich-text editor button is a CKEditor 5 plugin loaded as an ES module.

The analysis pipeline, started by AnalysisOrchestrator::analyze() :

PageContentExtractor::extractFromPage()       page data from the database
RenderedHeadingExtractor::extract()           headings from the rendered page (optional)
AccessibilityAnalyzer / SeoAnalyzer /
ReadabilityAnalyzer / DuplicateContentAnalyzer  rule-based issues
SchemaAdvisor                                 PageIntentDetector → SchemaMapper
                                              → JsonLdGenerator → SchemaValidator
AiAnalyzer, InternalLinkAnalyzer              only if AI is enabled
ScoreCalculator                               scores
→ tx_t3contentquality_result + tx_t3contentquality_history
Copied!

Using the analysis in your own code 

Inject \Woit\T3ContentQuality\Service\AnalysisOrchestrator :

EXT:my_extension/Classes/Command/AnalyzeCommand.php
use Woit\T3ContentQuality\Service\AnalysisOrchestrator;

final class AnalyzeCommand extends Command
{
    public function __construct(
        private readonly AnalysisOrchestrator $analysisOrchestrator,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $result = $this->analysisOrchestrator->analyze(pageUid: 42, languageUid: 0);
        $output->writeln('Overall score: ' . $result->overallScore);
        return Command::SUCCESS;
    }
}
Copied!

Public methods of AnalysisOrchestrator :

analyze(int $pageUid, int $languageUid = 0): AnalysisResult
Runs all checks and stores the result.
getStoredResult(int $pageUid, int $languageUid = 0): ?array
Returns the stored result without analysing again.
analyzeSchemaWithAi(int $pageUid, int $languageUid = 0): string
Runs AI schema detection and updates the stored result. Returns a string starting with ok: or error:.
checkLinks(int $pageUid, int $languageUid, LinkChecker $linkChecker): array
Checks the links of the page and stores the result.
getHistory(int $pageUid, int $languageUid = 0, int $limit = 20): array
Score history of a page.
getAllResults(): array
Stored results of all pages in the default language.
getUnanalyzedPageCount(): int
Number of visible standard pages without a result.

Domain models 

AnalysisIssue 

Immutable value object:

use Woit\T3ContentQuality\Domain\Model\AnalysisIssue;

$issue = new AnalysisIssue(
    category: AnalysisIssue::CATEGORY_ACCESSIBILITY,
    severity: AnalysisIssue::SEVERITY_ERROR,
    message: 'Image has no ALT text.',
    suggestion: 'Add a descriptive ALT text.',
);
Copied!

Categories: CATEGORY_ACCESSIBILITY, CATEGORY_SEO, CATEGORY_READABILITY, CATEGORY_SCHEMA. Severities: SEVERITY_ERROR, SEVERITY_WARNING, SEVERITY_INFO.

AnalysisResult 

Collects issues and scores during an analysis:

$result->overallScore;          // int, average of accessibility, SEO, readability
$result->accessibilityScore;    // int 0–100
$result->seoScore;              // int 0–100
$result->readabilityScore;      // int 0–100
$result->schemaScore;           // int 0–100
$result->providerUsed;          // string
$result->modelUsed;             // string
$result->metadata;              // array, page facts
$result->getIssues();           // AnalysisIssue[]
$result->getIssuesByCategory('seo');
$result->getAiSuggestions();    // string[]
Copied!

Page data 

PageContentExtractor::extractFromPage() returns an array with the keys page_uid, page_title, seo_title, meta_description, abstract, author, slug, doktype, crdate, tstamp, schema_type_override, headings (list of level and text), images, links (list of text and href), text_content and ctypes. AnalysisOrchestrator adds page_url.

Other services 

\Woit\T3ContentQuality\Security\PermissionService
canShowPage() , canEditPage() , canEditPageContent() , canApplyFixType() and filterShowablePageUids() for the current backend user.
\Woit\T3ContentQuality\Service\PageFixApplier
Stores AI proposals as pending rows and applies them with applyRows() through the DataHandler.
\Woit\T3ContentQuality\Domain\Repository\ApprovedSchemaRepository
Reads approved JSON-LD. Used by the frontend middleware.
\Woit\T3ContentQuality\Service\OllamaEndpoint
Resolves the Ollama URL from ollamaUrl.

Events used 

\TYPO3\CMS\Backend\Controller\Event\ModifyPageLayoutContentEvent
PageModuleContentListener adds the quality panel to the page module.
\TYPO3\CMS\Filelist\Event\ProcessFileListActionsEvent
FilelistActionsEventListener adds the metadata button to image rows in the file list.

Both listeners are registered in Configuration/Services.yaml with the identifiers t3contentquality.pageModulePanel and t3contentquality.filelistAiMetadata. To remove a listener, override its service definition in your own Services.yaml.

Backend routes 

All routes require a backend login and the TYPO3 route token. Build URLs with \TYPO3\CMS\Backend\Routing\UriBuilder . Each route checks the page permissions listed in Page permissions; requests for pages the user may not access are ignored.

Route identifier Method Purpose
t3contentquality_analyze GET Analyse pageUid/languageUid, redirect to the page module.
t3contentquality_fix GET Generate pending fixes; fixType is title, description, alt_text or all.
t3contentquality_pending_apply GET, POST Apply pending fixes of a page, or the fixes listed in uids.
t3contentquality_pending_discard GET, POST Discard pending fixes of a page, or the fixes listed in uids.
t3contentquality_bulk_fix_generate POST Generate pending fixes for pageUids (max. 15) of type fixType.
t3contentquality_check_links GET Check the links of a page.
t3contentquality_schema_ai_detect GET AI schema detection.
t3contentquality_schema_approve POST Store jsonld as approved schema.
t3contentquality_schema_reject GET Delete the approved schema.
t3contentquality_schema_export GET CSV export.
t3contentquality_ai_file_metadata GET Fill metadata of the image fileUid, redirect to the metadata form.

AJAX route t3contentquality_generate_text (POST, JSON body with prompt, tone, language, format, maxChars, languageUid) returns {"text": "…"} or {"error": "…"}.

Frontend middleware 

\Woit\T3ContentQuality\Middleware\JsonLdInjector is registered as woit/t3-content-quality/json-ld-injector. It re-encodes the stored JSON-LD with JSON_HEX_TAG, so < and > in values can not end the <script> element. To disable it, for example if you render JSON-LD yourself:

EXT:my_sitepackage/Configuration/RequestMiddlewares.php
return [
    'frontend' => [
        'woit/t3-content-quality/json-ld-injector' => [
            'disabled' => true,
        ],
    ],
];
Copied!

Schema types 

Supported schema.org types and their required and recommended properties are defined in \Woit\T3ContentQuality\Schema\SchemaTypes . Rule-based detection is in PageIntentDetector , the mapping of page data to properties in SchemaMapper . Both are regular services and can be replaced in Services.yaml.

AI providers 

All AI requests go through \Woit\T3ContentQuality\Service\AiProviderClient :

call(string $prompt, AiSettings $settings): string
Text prompt, returns the model's answer.
callVision(string $prompt, string $imageBase64, string $mimeType, AiSettings $settings): string
Prompt plus one image.

\Woit\T3ContentQuality\Service\AiSettingsFactory::create() reads the extension configuration and returns an immutable \Woit\T3ContentQuality\Service\AiSettings object (provider, key, model, limits). The default model IDs are defined only there.

Requests are sent with TYPO3's \TYPO3\CMS\Core\Http\RequestFactory instead of the vendor SDKs, so the extension also works in classic mode installations without Composer. For Anthropic the client

  • sends output_config.effort and fallbacks: "default" only to models that accept them (see anthropicEffort and anthropicFallback),
  • reads the answer by content block type, because thinking blocks can come first,
  • turns stop_reason: refusal into an exception with the refusal category.

To add a provider, add a match arm in AiProviderClient and the provider's settings in AiSettingsFactory and ext_conf_template.txt.

JavaScript modules 

Configuration/JavaScriptModules.php maps the import prefix @woit/t3-content-quality/ to EXT:t3_content_quality/Resources/Public/JavaScript/.

CKEditor/ai-text-generator.js
CKEditor 5 plugin AiTextGenerator with the toolbar item aiTextGenerator.
backend-loader.js, panel-loader.js
Loading indicators for the module and the panel.

Image metadata services 

The file list button ( AiFileMetadataController ) and the console command ( GenerateFileMetadataCommand ) share the same services:

\Woit\T3ContentQuality\Service\FileMetadataGenerator
generate(File $file): array fills the empty fields of the default-language metadata record: EXIF/IPTC data, download_name from the file name, then alternative text, title, description and caption via AiImageMetadataAnalyzer . Fields without a database column are skipped. Existing values are never overwritten. Returns the written fields (field name => value); an empty array means nothing was written.
\Woit\T3ContentQuality\Service\FileMetadataCandidateFinder
findFileUids(int $afterUid, int $batchSize, int $storageUid = 0, string $folder = ''): array returns uids of images with an empty alternative text, ascending. Only JPEG, PNG, GIF and WebP files up to 5 MB in existing storages are returned, and files flagged as missing are excluded. Use $afterUid as a paging cursor.

Example:

EXT:my_extension/Classes/Service/MyImageImport.php
use TYPO3\CMS\Core\Resource\File;
use Woit\T3ContentQuality\Service\FileMetadataGenerator;

final class MyImageImport
{
    public function __construct(
        private readonly FileMetadataGenerator $fileMetadataGenerator,
    ) {}

    public function afterImport(File $file): void
    {
        $writtenFields = $this->fileMetadataGenerator->generate($file);
    }
}
Copied!

The command GenerateFileMetadataCommand pages through the candidates with FileMetadataCandidateFinder , skips files that are missing on disk or in an offline storage (they do not count against --limit) and returns Command::FAILURE if the AI is not usable or a file throws an exception. Options are described in Bulk image metadata (CLI and scheduler).

Tests 

Unit tests are in Tests/Unit/ and use PHPUnit 11. They cover the score calculation, schema validation and type detection, the JSON-LD escaping in the middleware, the AI settings, the request and response handling of AiProviderClient , the parsing of AI answers, the image metadata generation ( FileMetadataGenerator ) and the bulk command ( GenerateFileMetadataCommand ). No network access or database is needed.

In a standalone checkout of the extension:

composer install
composer test:unit
Copied!

In a TYPO3 project that has PHPUnit installed:

vendor/bin/phpunit -c vendor/woit/t3-content-quality/Build/phpunit/UnitTests.xml
Copied!

If the extension is a path package in a project (for example packages/t3_content_quality/ in DDEV) and the TYPO3 Composer plugins are not allowed for it, install the test dependencies without plugins:

cd packages/t3_content_quality
composer install --no-plugins --no-scripts
vendor/bin/phpunit -c Build/phpunit/UnitTests.xml
Copied!

Permission checks, the DataHandler integration and the database query of FileMetadataCandidateFinder need a database and are not covered by unit tests.