Files
moko-platform/cli/joomla_build.php
Jonathan Miller e19ca4d7a9 feat(ci): type-aware Joomla build via PHP API (#20, #21)
Add cli/joomla_build.php — standalone build tool that detects all
Joomla extension types from the XML manifest and builds accordingly:
  - plugin, module, component, template, library, file: flat ZIP
  - package: nested ZIPs for each sub-extension in packages/

Update both workflows to call joomla_build.php via the moko-platform
PHP API instead of inlining bash build logic.

Also extends joomla_release.php with:
  - typePrefix() for correct naming (plg_, mod_, com_, tpl_, pkg_, lib_)
  - buildPackageZip() for multi-extension package assembly
  - copyDir() helper

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 16:43:07 -05:00

296 lines
10 KiB
PHP

#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/joomla_build.php
* VERSION: 05.00.01
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported
* NOTE: Called by pre-release and auto-release workflows.
*
* USAGE
* php joomla_build.php --path . --version 02.01.24
* php joomla_build.php --path . --version 02.01.24 --suffix -dev
* php joomla_build.php --path . --version 02.01.24 --output build --github-output
*
* Supports: plugin, module, component, template, package, library, file
*/
declare(strict_types=1);
// ── Argument parsing ────────────────────────────────────────────────────
$path = '.';
$version = '';
$suffix = '';
$outputDir = 'build';
$ghOutput = false;
foreach ($argv as $i => $arg) {
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
if ($arg === '--suffix' && isset($argv[$i + 1])) $suffix = $argv[$i + 1];
if ($arg === '--output' && isset($argv[$i + 1])) $outputDir = $argv[$i + 1];
if ($arg === '--github-output') $ghOutput = true;
}
if ($version === '') {
fwrite(STDERR, "::error::--version is required\n");
exit(1);
}
$path = realpath($path) ?: $path;
// ── Find source directory ──────────────────────────────────────────────
$srcDir = null;
foreach (['src', 'htdocs'] as $d) {
if (is_dir("{$path}/{$d}")) { $srcDir = "{$path}/{$d}"; break; }
}
if ($srcDir === null) {
fwrite(STDERR, "::error::No src/ or htdocs/ directory in {$path}\n");
exit(1);
}
// ── Find manifest ──────────────────────────────────────────────────────
$manifest = findManifest($srcDir);
if ($manifest === null) {
fwrite(STDERR, "::error::No Joomla manifest found in {$srcDir}\n");
exit(1);
}
fwrite(STDERR, "Manifest: {$manifest}\n");
// ── Parse manifest ─────────────────────────────────────────────────────
$meta = parseManifest($manifest);
// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS")
if (preg_match('/^[A-Z_]+$/', $meta['name'])) {
$resolved = resolveLanguageKey($srcDir, $meta['name']);
if ($resolved !== null) { $meta['name'] = $resolved; }
}
$prefix = typePrefix($meta);
$zipName = "{$prefix}{$meta['element']}-{$version}{$suffix}.zip";
$zipPath = "{$outputDir}/{$zipName}";
fwrite(STDERR, "=== Joomla Build: {$meta['type']}{$meta['element']} {$version}{$suffix} ===\n");
fwrite(STDERR, " Type: {$meta['type']}\n");
fwrite(STDERR, " Element: {$meta['element']}\n");
fwrite(STDERR, " Group: " . ($meta['group'] ?: 'n/a') . "\n");
fwrite(STDERR, " Name: {$meta['name']}\n");
fwrite(STDERR, " Output: {$zipName}\n");
// ── Build ──────────────────────────────────────────────────────────────
if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); }
if ($meta['type'] === 'package') {
buildPackageZip($srcDir, $zipPath);
} else {
buildZip($srcDir, $zipPath);
}
$sha256 = hash_file('sha256', $zipPath);
$size = filesize($zipPath);
fwrite(STDERR, "Package: {$zipPath} ({$size} bytes, SHA: " . substr($sha256, 0, 16) . "...)\n");
// ── Output variables ───────────────────────────────────────────────────
$vars = [
'zip_name' => $zipName,
'zip_path' => $zipPath,
'sha256' => $sha256,
'ext_type' => $meta['type'],
'ext_element' => $meta['element'],
'ext_name' => $meta['name'],
'ext_group' => $meta['group'],
'type_prefix' => $prefix,
];
if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') {
$fh = fopen($ghFile, 'a');
foreach ($vars as $k => $v) { fwrite($fh, "{$k}={$v}\n"); }
fclose($fh);
fwrite(STDERR, "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT\n");
} else {
foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; }
}
exit(0);
// ═══════════════════════════════════════════════════════════════════════
// Functions
// ═══════════════════════════════════════════════════════════════════════
function findManifest(string $dir): ?string
{
// Priority: pkg_*.xml (packages), then any *.xml with <extension>
foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { return $f; }
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
if (str_contains((string) file_get_contents($f), '<extension')) { return $f; }
}
// Broader nested search
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $item) {
if ($item->isFile() && $item->getExtension() === 'xml') {
if (str_contains((string) file_get_contents($item->getPathname()), '<extension')) {
return $item->getPathname();
}
}
}
return null;
}
function parseManifest(string $file): array
{
$xml = simplexml_load_file($file);
$name = (string) ($xml->name ?? '');
$type = (string) ($xml->attributes()->type ?? 'component');
$element = (string) ($xml->element ?? '');
$group = (string) ($xml->attributes()->group ?? '');
// Fallback element detection
if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); }
if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); }
if ($element === '') {
$element = strtolower(basename($file, '.xml'));
if (in_array($element, ['templatedetails', 'manifest'], true)) {
$element = strtolower(basename(dirname($file)));
}
}
if ($name === '') { $name = $element; }
return compact('name', 'type', 'element', 'group');
}
function typePrefix(array $meta): string
{
return match ($meta['type']) {
'plugin' => "plg_{$meta['group']}_",
'module' => 'mod_',
'component' => 'com_',
'template' => 'tpl_',
'package' => 'pkg_',
'library' => 'lib_',
default => '',
};
}
function resolveLanguageKey(string $srcDir, string $key): ?string
{
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS)
);
foreach ($iter as $item) {
if ($item->isFile() && str_ends_with($item->getFilename(), '.sys.ini')) {
foreach (file($item->getPathname()) as $line) {
if (preg_match('/^' . preg_quote($key, '/') . '="(.+)"/', trim($line), $m)) {
return $m[1];
}
}
}
}
return null;
}
function isExcluded(string $name): bool
{
if ($name === '.ftpignore') return true;
if (str_starts_with($name, 'sftp-config')) return true;
if (str_starts_with($name, '.env')) return true;
if (str_starts_with($name, '.build-trigger')) return true;
$ext = pathinfo($name, PATHINFO_EXTENSION);
return in_array($ext, ['ppk', 'pem', 'key', 'local'], true);
}
function buildZip(string $srcDir, string $outPath): void
{
$zip = new ZipArchive();
if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
fwrite(STDERR, "::error::Cannot create ZIP: {$outPath}\n");
exit(1);
}
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $file) {
$local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1));
if (isExcluded(basename($local))) continue;
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
}
$zip->close();
}
function buildPackageZip(string $srcDir, string $outPath): void
{
fwrite(STDERR, "Building Joomla package (multi-extension)...\n");
$staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid();
mkdir($staging, 0755, true);
// 1. Zip each sub-extension in packages/
$packagesDir = "{$srcDir}/packages";
if (is_dir($packagesDir)) {
foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) {
$subManifest = findManifest($extDir);
if ($subManifest) {
$sub = parseManifest($subManifest);
$subPrefix = typePrefix($sub);
$subZipName = "{$subPrefix}{$sub['element']}.zip";
} else {
$subZipName = basename($extDir) . '.zip';
}
fwrite(STDERR, " Sub-extension: {$subZipName}\n");
buildZip($extDir, "{$staging}/{$subZipName}");
}
}
// 2. Copy package-level files (manifest, script, language)
foreach (glob("{$srcDir}/*.xml") ?: [] as $f) copy($f, "{$staging}/" . basename($f));
foreach (glob("{$srcDir}/*.php") ?: [] as $f) copy($f, "{$staging}/" . basename($f));
foreach (['language', 'administrator'] as $d) {
if (is_dir("{$srcDir}/{$d}")) {
copyTree("{$srcDir}/{$d}", "{$staging}/{$d}");
}
}
// 3. Create outer zip
buildZip($staging, $outPath);
// Cleanup
rmTree($staging);
}
function copyTree(string $src, string $dst): void
{
if (!is_dir($dst)) mkdir($dst, 0755, true);
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iter as $item) {
$target = "{$dst}/" . $iter->getSubPathname();
$item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target);
}
}
function rmTree(string $dir): void
{
if (!is_dir($dir)) return;
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iter as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($dir);
}