4cc3f5bee4
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 14s
Generic: Repo Health / Repository health (pull_request) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 44s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 49s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
- Convert tabs to spaces (3,413 violations) - Fix line endings, trailing whitespace, brace placement - Break lines exceeding 150-char absolute limit - Replace heredoc tab closers with spaces - Fix empty elseif, forbidden function calls - Update phpcs.xml: exclude rules inappropriate for CLI scripts (SideEffects, MissingNamespace, MultipleClasses, HeaderOrder, empty catch blocks) Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
421 lines
14 KiB
PHP
421 lines
14 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
*
|
|
* This file is part of a Moko Consulting project.
|
|
*
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*
|
|
* FILE INFORMATION
|
|
* DEFGROUP: MokoStandards.Joomla
|
|
* INGROUP: MokoStandards
|
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
|
* PATH: /lib/plugins/Joomla/UpdateXmlGenerator.php
|
|
* BRIEF: Generates and updates Joomla extension updates.xml files
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace MokoStandards\Plugins\Joomla;
|
|
|
|
use DOMDocument;
|
|
use DOMElement;
|
|
use Exception;
|
|
|
|
/**
|
|
* Joomla Update XML Generator
|
|
*
|
|
* Generates and updates updates.xml files for Joomla extensions
|
|
* following the Joomla update server specification
|
|
*/
|
|
class UpdateXmlGenerator
|
|
{
|
|
private string $extensionName;
|
|
private string $extensionType;
|
|
private string $element;
|
|
private string $clientId;
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param string $extensionName Human-readable extension name
|
|
* @param string $extensionType Extension type (component, module, plugin, etc.)
|
|
* @param string $element Extension element (e.g., com_example, mod_custom)
|
|
* @param string $clientId Client ID (0 for site, 1 for admin)
|
|
*/
|
|
public function __construct(
|
|
string $extensionName,
|
|
string $extensionType = 'component',
|
|
string $element = '',
|
|
string $clientId = '0'
|
|
) {
|
|
$this->extensionName = $extensionName;
|
|
$this->extensionType = $extensionType;
|
|
$this->element = $element ?: $this->deriveElement($extensionName, $extensionType);
|
|
$this->clientId = $clientId;
|
|
}
|
|
|
|
/**
|
|
* Generate updates.xml from release information
|
|
*
|
|
* @param array $release Release information
|
|
* @return string XML content
|
|
*/
|
|
public function generate(array $release): string
|
|
{
|
|
$dom = new DOMDocument('1.0', 'UTF-8');
|
|
$dom->formatOutput = true;
|
|
$dom->preserveWhiteSpace = false;
|
|
|
|
// Create root element
|
|
$updates = $dom->createElement('updates');
|
|
$dom->appendChild($updates);
|
|
|
|
// Add update entry
|
|
$this->addUpdateEntry($dom, $updates, $release);
|
|
|
|
return $dom->saveXML();
|
|
}
|
|
|
|
/**
|
|
* Update existing updates.xml file with new release
|
|
*
|
|
* @param string $xmlPath Path to existing updates.xml
|
|
* @param array $release New release information
|
|
* @return string Updated XML content
|
|
* @throws Exception If XML cannot be parsed
|
|
*/
|
|
public function update(string $xmlPath, array $release): string
|
|
{
|
|
if (!file_exists($xmlPath)) {
|
|
return $this->generate($release);
|
|
}
|
|
|
|
$dom = new DOMDocument('1.0', 'UTF-8');
|
|
$dom->formatOutput = true;
|
|
$dom->preserveWhiteSpace = false;
|
|
|
|
if (!@$dom->load($xmlPath)) {
|
|
throw new Exception("Failed to parse existing updates.xml at {$xmlPath}");
|
|
}
|
|
|
|
$updates = $dom->getElementsByTagName('updates')->item(0);
|
|
if (!$updates) {
|
|
throw new Exception("Invalid updates.xml: missing <updates> root element");
|
|
}
|
|
|
|
// Check if this version already exists
|
|
$version = $release['version'];
|
|
$existingUpdates = $updates->getElementsByTagName('update');
|
|
|
|
foreach ($existingUpdates as $existingUpdate) {
|
|
$versionNode = $existingUpdate->getElementsByTagName('version')->item(0);
|
|
if ($versionNode && $versionNode->textContent === $version) {
|
|
// Remove existing entry for this version
|
|
$updates->removeChild($existingUpdate);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Add new update entry at the beginning
|
|
$this->addUpdateEntry($dom, $updates, $release, true);
|
|
|
|
return $dom->saveXML();
|
|
}
|
|
|
|
/**
|
|
* Map numeric client ID to Joomla client name
|
|
*
|
|
* @param string $clientId Numeric client ID
|
|
* @return string Client name for updates.xml
|
|
*/
|
|
private function resolveClientName(string $clientId): string
|
|
{
|
|
return match ($clientId) {
|
|
'1' => 'administrator',
|
|
default => 'site',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Add an update entry to the XML document
|
|
*
|
|
* @param DOMDocument $dom DOM document
|
|
* @param DOMElement $updates Updates element
|
|
* @param array $release Release information
|
|
* @param bool $prepend Whether to prepend (insert at beginning)
|
|
*/
|
|
private function addUpdateEntry(
|
|
DOMDocument $dom,
|
|
DOMElement $updates,
|
|
array $release,
|
|
bool $prepend = false
|
|
): void {
|
|
$update = $dom->createElement('update');
|
|
|
|
// Required fields
|
|
$this->addElement($dom, $update, 'name', $this->extensionName);
|
|
$this->addElement($dom, $update, 'description', $release['description'] ?? '');
|
|
$this->addElement($dom, $update, 'element', $this->element);
|
|
$this->addElement($dom, $update, 'type', $this->extensionType);
|
|
|
|
// Folder (for plugins)
|
|
if (!empty($release['folder'])) {
|
|
$this->addElement($dom, $update, 'folder', $release['folder']);
|
|
}
|
|
|
|
// Client — always emit for correct extension matching
|
|
$this->addElement($dom, $update, 'client', $this->resolveClientName($this->clientId));
|
|
|
|
$this->addElement($dom, $update, 'version', $release['version']);
|
|
|
|
// Creation date
|
|
if (!empty($release['creation_date'])) {
|
|
$this->addElement($dom, $update, 'creationDate', $release['creation_date']);
|
|
}
|
|
|
|
// Joomla target platform
|
|
$infourl = $this->addElement($dom, $update, 'infourl', $release['infourl'] ?? '');
|
|
if (!empty($release['infourl'])) {
|
|
$infourl->setAttribute('title', 'Release Information');
|
|
}
|
|
|
|
// Downloads section
|
|
$downloads = $dom->createElement('downloads');
|
|
$update->appendChild($downloads);
|
|
|
|
$downloadUrl = $this->addElement($dom, $downloads, 'downloadurl', $release['download_url']);
|
|
$downloadUrl->setAttribute('type', 'full');
|
|
$downloadUrl->setAttribute('format', 'zip');
|
|
|
|
// Checksums
|
|
if (!empty($release['sha256'])) {
|
|
$this->addElement($dom, $update, 'sha256', $release['sha256']);
|
|
}
|
|
|
|
if (!empty($release['sha384'])) {
|
|
$this->addElement($dom, $update, 'sha384', $release['sha384']);
|
|
}
|
|
|
|
if (!empty($release['sha512'])) {
|
|
$this->addElement($dom, $update, 'sha512', $release['sha512']);
|
|
}
|
|
|
|
// Tags
|
|
if (!empty($release['tags'])) {
|
|
$tags = $dom->createElement('tags');
|
|
$update->appendChild($tags);
|
|
foreach ($release['tags'] as $tag) {
|
|
$this->addElement($dom, $tags, 'tag', $tag);
|
|
}
|
|
}
|
|
|
|
// Maintainer information
|
|
if (!empty($release['maintainer'])) {
|
|
$this->addElement($dom, $update, 'maintainer', $release['maintainer']);
|
|
}
|
|
|
|
if (!empty($release['maintainer_url'])) {
|
|
$this->addElement($dom, $update, 'maintainerurl', $release['maintainer_url']);
|
|
}
|
|
|
|
// Target platform
|
|
if (!empty($release['target_platform'])) {
|
|
$targetPlatform = $dom->createElement('targetplatform');
|
|
$targetPlatform->setAttribute('name', 'joomla');
|
|
$targetPlatform->setAttribute('version', $release['target_platform']);
|
|
$update->appendChild($targetPlatform);
|
|
}
|
|
|
|
// Optional: PHP minimum version
|
|
if (!empty($release['php_minimum'])) {
|
|
$this->addElement($dom, $update, 'php_minimum', $release['php_minimum']);
|
|
}
|
|
|
|
// Add to updates element
|
|
if ($prepend && $updates->firstChild) {
|
|
$updates->insertBefore($update, $updates->firstChild);
|
|
} else {
|
|
$updates->appendChild($update);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a text element to parent
|
|
*
|
|
* @param DOMDocument $dom DOM document
|
|
* @param DOMElement $parent Parent element
|
|
* @param string $name Element name
|
|
* @param string $value Element value
|
|
* @return DOMElement Created element
|
|
*/
|
|
private function addElement(
|
|
DOMDocument $dom,
|
|
DOMElement $parent,
|
|
string $name,
|
|
string $value
|
|
): DOMElement {
|
|
$element = $dom->createElement($name);
|
|
$element->textContent = $value;
|
|
$parent->appendChild($element);
|
|
return $element;
|
|
}
|
|
|
|
/**
|
|
* Derive element name from extension name and type
|
|
*
|
|
* @param string $name Extension name
|
|
* @param string $type Extension type
|
|
* @return string Element name
|
|
*/
|
|
private function deriveElement(string $name, string $type): string
|
|
{
|
|
$prefix = match ($type) {
|
|
'component' => 'com_',
|
|
'module' => 'mod_',
|
|
'plugin' => 'plg_',
|
|
'library' => 'lib_',
|
|
'template' => 'tpl_',
|
|
'package' => 'pkg_',
|
|
default => '',
|
|
};
|
|
|
|
// Convert name to lowercase and replace spaces with underscores
|
|
$element = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $name));
|
|
|
|
// Add prefix if not already present
|
|
if (!str_starts_with($element, $prefix)) {
|
|
$element = $prefix . $element;
|
|
}
|
|
|
|
return $element;
|
|
}
|
|
|
|
/**
|
|
* Validate updates.xml structure
|
|
*
|
|
* @param string $xmlContent XML content to validate
|
|
* @return array Validation result ['valid' => bool, 'errors' => array]
|
|
*/
|
|
public static function validate(string $xmlContent): array
|
|
{
|
|
$errors = [];
|
|
|
|
$dom = new DOMDocument();
|
|
libxml_use_internal_errors(true);
|
|
|
|
if (!$dom->loadXML($xmlContent)) {
|
|
foreach (libxml_get_errors() as $error) {
|
|
$errors[] = "XML Error: {$error->message}";
|
|
}
|
|
libxml_clear_errors();
|
|
return ['valid' => false, 'errors' => $errors];
|
|
}
|
|
|
|
// Validate structure
|
|
$updates = $dom->getElementsByTagName('updates')->item(0);
|
|
if (!$updates) {
|
|
$errors[] = "Missing <updates> root element";
|
|
return ['valid' => false, 'errors' => $errors];
|
|
}
|
|
|
|
$updateElements = $updates->getElementsByTagName('update');
|
|
if ($updateElements->length === 0) {
|
|
$errors[] = "No <update> elements found";
|
|
return ['valid' => false, 'errors' => $errors];
|
|
}
|
|
|
|
// Validate each update entry
|
|
foreach ($updateElements as $update) {
|
|
$required = ['name', 'element', 'type', 'version'];
|
|
foreach ($required as $field) {
|
|
if ($update->getElementsByTagName($field)->length === 0) {
|
|
$errors[] = "Missing required field: <{$field}>";
|
|
}
|
|
}
|
|
|
|
// Warn if <client> is missing
|
|
if ($update->getElementsByTagName('client')->length === 0) {
|
|
$errors[] = "Missing <client> tag — Joomla may not match this update to the installed extension";
|
|
}
|
|
|
|
// Check for download URL
|
|
$downloads = $update->getElementsByTagName('downloads');
|
|
if ($downloads->length > 0) {
|
|
$downloadUrl = $downloads->item(0)->getElementsByTagName('downloadurl');
|
|
if ($downloadUrl->length === 0) {
|
|
$errors[] = "Missing <downloadurl> in <downloads>";
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'valid' => empty($errors),
|
|
'errors' => $errors
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Extract release information from manifest XML
|
|
*
|
|
* @param string $manifestPath Path to extension manifest XML
|
|
* @return array Release information
|
|
* @throws Exception If manifest cannot be parsed
|
|
*/
|
|
public static function extractFromManifest(string $manifestPath): array
|
|
{
|
|
if (!file_exists($manifestPath)) {
|
|
throw new Exception("Manifest file not found: {$manifestPath}");
|
|
}
|
|
|
|
$dom = new DOMDocument();
|
|
if (!@$dom->load($manifestPath)) {
|
|
throw new Exception("Failed to parse manifest XML: {$manifestPath}");
|
|
}
|
|
|
|
$root = $dom->documentElement;
|
|
|
|
return [
|
|
'name' => self::getElementText($dom, 'name') ?: 'Unknown Extension',
|
|
'version' => self::getElementText($dom, 'version') ?: '1.0.0',
|
|
'description' => self::getElementText($dom, 'description') ?: '',
|
|
'author' => self::getElementText($dom, 'author') ?: '',
|
|
'author_url' => self::getElementText($dom, 'authorUrl') ?: '',
|
|
'type' => $root->getAttribute('type') ?: 'component',
|
|
'target_platform' => self::getElementText($dom, 'version', 'targetplatform') ?: '4.0',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get text content of an element
|
|
*
|
|
* @param DOMDocument $dom DOM document
|
|
* @param string $tagName Tag name
|
|
* @param string $parentTag Optional parent tag name
|
|
* @return string|null Element text content
|
|
*/
|
|
private static function getElementText(
|
|
DOMDocument $dom,
|
|
string $tagName,
|
|
string $parentTag = ''
|
|
): ?string {
|
|
if ($parentTag) {
|
|
$parents = $dom->getElementsByTagName($parentTag);
|
|
if ($parents->length > 0) {
|
|
$elements = $parents->item(0)->getElementsByTagName($tagName);
|
|
if ($elements->length > 0) {
|
|
return trim($elements->item(0)->textContent);
|
|
}
|
|
}
|
|
} else {
|
|
$elements = $dom->getElementsByTagName($tagName);
|
|
if ($elements->length > 0) {
|
|
return trim($elements->item(0)->textContent);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|