e8da1a30ff
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 10s
Generic: Repo Health / Scripts governance (push) Successful in 9s
Generic: Repo Health / Repository health (push) Successful in 17s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 8s
Universal: PR Check / Validate PR (pull_request) Successful in 8s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 8s
Universal: PR Check / Build RC Package (pull_request) Successful in 5s
Generic: Repo Health / Repository health (pull_request) Successful in 19s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m16s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m29s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 41s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 6s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 1m38s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m40s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m41s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 1m40s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 1m20s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 1m24s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 1m24s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 1m26s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 1m30s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 12s
Removed 13 write-only properties and unused code. Remaining 41 baselined items are defensive patterns (null coalesce on API responses, boolean safety checks) that are intentional. PHPStan level 4: 0 errors with baseline. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
938 lines
33 KiB
PHP
938 lines
33 KiB
PHP
#!/usr/bin/env 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.Automation
|
|
* INGROUP: MokoStandards.Scripts
|
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
|
* PATH: /automation/bulk_joomla_template.php
|
|
* BRIEF: Bulk scaffold and sync Joomla template repositories
|
|
*
|
|
* USAGE
|
|
* php automation/bulk_joomla_template.php --scaffold --name=MokoTheme
|
|
* php automation/bulk_joomla_template.php --scaffold --name=MokoTheme --client=administrator
|
|
* php automation/bulk_joomla_template.php --sync --repos=MokoTheme,MokoDarkTheme
|
|
* php automation/bulk_joomla_template.php --sync --all
|
|
* php automation/bulk_joomla_template.php --list
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../vendor/autoload.php';
|
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
|
|
|
use MokoEnterprise\{
|
|
AuditLogger,
|
|
CliFramework,
|
|
Config,
|
|
GitPlatformAdapter,
|
|
MetricsCollector,
|
|
PlatformAdapterFactory
|
|
};
|
|
|
|
/**
|
|
* Bulk Joomla Template Manager
|
|
*
|
|
* Provides three operations for Joomla template projects:
|
|
* --scaffold: Create a new template repository with the full directory structure
|
|
* --sync: Push MokoStandards files to existing template repositories
|
|
* --list: List all repositories tagged as joomla-template
|
|
*
|
|
* Works with both GitHub and Gitea via the PlatformAdapterFactory.
|
|
*/
|
|
class BulkJoomlaTemplate extends CliFramework
|
|
{
|
|
public const DEFAULT_ORG = 'MokoConsulting';
|
|
public const VERSION = '04.06.10';
|
|
|
|
private GitPlatformAdapter $adapter;
|
|
private Config $config;
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this->setDescription('Bulk Joomla template management');
|
|
$this->addArgument('--org', 'Organization', self::DEFAULT_ORG);
|
|
$this->addArgument('--scaffold', 'Create new template repo', false);
|
|
$this->addArgument('--sync', 'Sync files to template repos', false);
|
|
$this->addArgument('--list', 'List template repos', false);
|
|
$this->addArgument('--name', 'Template name for scaffold', '');
|
|
$this->addArgument('--client', 'Joomla client: site or admin', 'site');
|
|
$this->addArgument('--repos', 'Target repos (comma-separated)', '');
|
|
$this->addArgument('--all', 'Sync all tagged repos', false);
|
|
$this->addArgument('--sync-updates', 'Sync updates.xml', false);
|
|
$this->addArgument('--private', 'Create as private repo', false);
|
|
$this->addArgument('--yes', 'Auto-confirm', false);
|
|
}
|
|
|
|
protected function run(): int
|
|
{
|
|
$this->log("🎨 Joomla Template Manager v" . self::VERSION, 'INFO');
|
|
|
|
$this->config = Config::load();
|
|
|
|
try {
|
|
$this->adapter = PlatformAdapterFactory::create($this->config);
|
|
} catch (\Exception $e) {
|
|
$this->log("❌ Failed to initialize: " . $e->getMessage(), 'ERROR');
|
|
return 1;
|
|
}
|
|
|
|
$org = $this->getArgument('--org', self::DEFAULT_ORG);
|
|
$platform = $this->adapter->getPlatformName();
|
|
$this->log("Platform: {$platform} | Organization: {$org}", 'INFO');
|
|
|
|
if ($this->getArgument('--list', false)) {
|
|
return $this->listTemplateRepos($org);
|
|
}
|
|
|
|
if ($this->getArgument('--scaffold', false)) {
|
|
return $this->scaffoldTemplate($org);
|
|
}
|
|
|
|
if ($this->getArgument('--sync', false)) {
|
|
return $this->syncTemplates($org);
|
|
}
|
|
|
|
if ($this->getArgument('--sync-updates', false)) {
|
|
return $this->syncUpdatesBetweenPlatforms($org);
|
|
}
|
|
|
|
$this->log("❌ Specify --scaffold, --sync, --sync-updates, or --list", 'ERROR');
|
|
return 1;
|
|
}
|
|
|
|
// ── List ─────────────────────────────────────────────────────────────
|
|
|
|
private function listTemplateRepos(string $org): int
|
|
{
|
|
$repos = $this->findTemplateRepos($org);
|
|
|
|
if (empty($repos)) {
|
|
$this->log("No joomla-template repositories found in {$org}", 'INFO');
|
|
return 0;
|
|
}
|
|
|
|
$this->log("\nJoomla template repositories in {$org}:", 'INFO');
|
|
foreach ($repos as $repo) {
|
|
$vis = ($repo['private'] ?? false) ? 'private' : 'public';
|
|
$url = $this->adapter->getRepoWebUrl($org, $repo['name']);
|
|
$this->log(" - {$repo['name']} ({$vis}) {$url}", 'INFO');
|
|
}
|
|
$this->log("\nTotal: " . count($repos), 'INFO');
|
|
|
|
return 0;
|
|
}
|
|
|
|
// ── Scaffold ─────────────────────────────────────────────────────────
|
|
|
|
private function scaffoldTemplate(string $org): int
|
|
{
|
|
$name = $this->getArgument('--name', '');
|
|
$client = $this->getArgument('--client', 'site');
|
|
$dryRun = $this->dryRun;
|
|
|
|
if (empty($name)) {
|
|
$this->log("❌ --name is required for --scaffold", 'ERROR');
|
|
$this->log(" Example: --name=MokoTheme", 'ERROR');
|
|
return 1;
|
|
}
|
|
|
|
if (!in_array($client, ['site', 'administrator'], true)) {
|
|
$this->log("❌ --client must be 'site' or 'administrator'", 'ERROR');
|
|
return 1;
|
|
}
|
|
|
|
$shortName = $this->deriveShortName($name);
|
|
$this->log("\nScaffolding Joomla template:", 'INFO');
|
|
$this->log(" Name: {$name}", 'INFO');
|
|
$this->log(" Short name: {$shortName}", 'INFO');
|
|
$this->log(" Client: {$client}", 'INFO');
|
|
$this->log(" Element: tpl_{$shortName}", 'INFO');
|
|
|
|
if ($dryRun) {
|
|
$this->log("\n[DRY RUN] Would create repository and scaffold files", 'INFO');
|
|
$this->printScaffoldPlan($shortName);
|
|
return 0;
|
|
}
|
|
|
|
// Check if repo already exists
|
|
try {
|
|
$this->adapter->getRepo($org, $name);
|
|
$this->log("❌ Repository {$org}/{$name} already exists", 'ERROR');
|
|
return 1;
|
|
} catch (\Exception $e) {
|
|
$this->adapter->getApiClient()->resetCircuitBreaker();
|
|
}
|
|
|
|
// Confirm
|
|
if (!$this->getArgument('--yes', false)) {
|
|
echo "\nCreate repository {$org}/{$name}? [y/N]: ";
|
|
$handle = fopen('php://stdin', 'r');
|
|
$line = fgets($handle);
|
|
if ($handle) {
|
|
fclose($handle);
|
|
}
|
|
if (!is_string($line) || strtolower(trim($line)) !== 'y') {
|
|
$this->log("Cancelled.", 'INFO');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// Create repository
|
|
$this->log("\nCreating repository...", 'INFO');
|
|
try {
|
|
$isPrivate = $this->getArgument('--private', false);
|
|
$this->adapter->createOrgRepo($org, $name, [
|
|
'description' => "Joomla {$client} template — {$name}",
|
|
'private' => $isPrivate,
|
|
'auto_init' => true,
|
|
]);
|
|
$this->log(" ✓ Repository created: {$org}/{$name}", 'INFO');
|
|
} catch (\Exception $e) {
|
|
$this->log("❌ Failed to create repository: " . $e->getMessage(), 'ERROR');
|
|
return 1;
|
|
}
|
|
|
|
// Set topics
|
|
try {
|
|
$this->adapter->setRepoTopics($org, $name, [
|
|
'joomla', 'joomla-template', 'template', "joomla-{$client}",
|
|
]);
|
|
$this->log(" ✓ Topics set", 'INFO');
|
|
} catch (\Exception $e) {
|
|
$this->log(" ⚠️ Could not set topics: " . $e->getMessage(), 'WARN');
|
|
$this->adapter->getApiClient()->resetCircuitBreaker();
|
|
}
|
|
|
|
// Scaffold files
|
|
$this->log("\nScaffolding template files...", 'INFO');
|
|
$files = $this->getScaffoldFiles($name, $shortName, $client, $org);
|
|
|
|
$created = 0;
|
|
foreach ($files as $path => $content) {
|
|
try {
|
|
$this->adapter->createOrUpdateFile(
|
|
$org,
|
|
$name,
|
|
$path,
|
|
$content,
|
|
"chore: scaffold {$path}"
|
|
);
|
|
$this->log(" ✓ {$path}", 'INFO');
|
|
$created++;
|
|
} catch (\Exception $e) {
|
|
$this->log(" ✗ {$path}: " . $e->getMessage(), 'ERROR');
|
|
$this->adapter->getApiClient()->resetCircuitBreaker();
|
|
}
|
|
}
|
|
|
|
// Apply branch protection
|
|
try {
|
|
$this->adapter->setBranchProtection($org, $name, 'main', [
|
|
'required_reviews' => 1,
|
|
'dismiss_stale' => true,
|
|
'block_on_rejected' => true,
|
|
]);
|
|
$this->log(" ✓ Branch protection applied", 'INFO');
|
|
} catch (\Exception $e) {
|
|
$this->log(" ⚠️ Branch protection: " . $e->getMessage(), 'WARN');
|
|
$this->adapter->getApiClient()->resetCircuitBreaker();
|
|
}
|
|
|
|
$url = $this->adapter->getRepoWebUrl($org, $name);
|
|
$this->log("\n✅ Template scaffolded: {$url}", 'INFO');
|
|
$this->log(" {$created} files created", 'INFO');
|
|
|
|
return 0;
|
|
}
|
|
|
|
// ── Sync ─────────────────────────────────────────────────────────────
|
|
|
|
private function syncTemplates(string $org): int
|
|
{
|
|
$repos = [];
|
|
|
|
if ($this->getArgument('--all', false)) {
|
|
$repos = $this->findTemplateRepos($org);
|
|
} else {
|
|
$reposArg = $this->getArgument('--repos', '');
|
|
if (empty($reposArg)) {
|
|
$this->log("❌ --repos or --all required for --sync", 'ERROR');
|
|
return 1;
|
|
}
|
|
$names = array_filter(array_map('trim', explode(',', $reposArg)));
|
|
foreach ($names as $name) {
|
|
$repos[] = ['name' => $name];
|
|
}
|
|
}
|
|
|
|
if (empty($repos)) {
|
|
$this->log("No template repositories to sync", 'INFO');
|
|
return 0;
|
|
}
|
|
|
|
$this->log("\nSyncing " . count($repos) . " template repo(s)...", 'INFO');
|
|
|
|
$dryRun = $this->dryRun;
|
|
$success = 0;
|
|
$failed = 0;
|
|
|
|
foreach ($repos as $repo) {
|
|
$name = $repo['name'];
|
|
$this->log("\n[{$name}]", 'INFO');
|
|
|
|
try {
|
|
$repoData = $this->adapter->getRepo($org, $name);
|
|
$shortName = $this->deriveShortName($name);
|
|
$branch = $repoData['default_branch'] ?? 'main';
|
|
|
|
$syncFiles = $this->getSyncFiles($name, $shortName);
|
|
|
|
$updated = 0;
|
|
foreach ($syncFiles as $path => $content) {
|
|
if ($dryRun) {
|
|
$this->log(" (dry-run) {$path}", 'INFO');
|
|
$updated++;
|
|
continue;
|
|
}
|
|
|
|
// Check if file exists
|
|
$existingSha = null;
|
|
try {
|
|
$existing = $this->adapter->getFileContents($org, $name, $path, $branch);
|
|
$existingSha = $existing['sha'] ?? null;
|
|
} catch (\Exception $e) {
|
|
$this->adapter->getApiClient()->resetCircuitBreaker();
|
|
}
|
|
|
|
try {
|
|
$this->adapter->createOrUpdateFile(
|
|
$org,
|
|
$name,
|
|
$path,
|
|
$content,
|
|
"chore: update {$path} from MokoStandards",
|
|
$existingSha,
|
|
$branch
|
|
);
|
|
$this->log(" ✓ {$path}", 'INFO');
|
|
$updated++;
|
|
} catch (\Exception $e) {
|
|
$this->log(" ✗ {$path}: " . $e->getMessage(), 'ERROR');
|
|
$this->adapter->getApiClient()->resetCircuitBreaker();
|
|
}
|
|
}
|
|
|
|
$this->log(" {$updated} file(s) synced", 'INFO');
|
|
$success++;
|
|
} catch (\Exception $e) {
|
|
$this->log(" ✗ {$name}: " . $e->getMessage(), 'ERROR');
|
|
$failed++;
|
|
$this->adapter->getApiClient()->resetCircuitBreaker();
|
|
}
|
|
}
|
|
|
|
$this->log("\n" . str_repeat('=', 50), 'INFO');
|
|
$this->log("Sync complete: {$success} succeeded, {$failed} failed", 'INFO');
|
|
|
|
return $failed > 0 ? 1 : 0;
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
private function findTemplateRepos(string $org): array
|
|
{
|
|
$allRepos = $this->adapter->listOrgRepos($org, true);
|
|
$templates = [];
|
|
|
|
foreach ($allRepos as $repo) {
|
|
try {
|
|
$topics = $this->adapter->getRepoTopics($org, $repo['name']);
|
|
if (in_array('joomla-template', $topics, true)) {
|
|
$templates[] = $repo;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->adapter->getApiClient()->resetCircuitBreaker();
|
|
}
|
|
}
|
|
|
|
return $templates;
|
|
}
|
|
|
|
private function deriveShortName(string $name): string
|
|
{
|
|
// MokoTheme → mokotheme, Moko-Dark-Theme → mokodarktheme
|
|
return strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $name));
|
|
}
|
|
|
|
private function printScaffoldPlan(string $shortName): void
|
|
{
|
|
$files = [
|
|
'templateDetails.xml',
|
|
'updates.xml',
|
|
'src/index.php',
|
|
'src/error.php',
|
|
'src/offline.php',
|
|
'src/component.php',
|
|
'src/html/index.html',
|
|
'src/css/.gitkeep',
|
|
'src/js/.gitkeep',
|
|
'src/images/.gitkeep',
|
|
"src/language/en-GB/tpl_{$shortName}.ini",
|
|
"src/language/en-GB/tpl_{$shortName}.sys.ini",
|
|
'media/css/.gitkeep',
|
|
'media/js/.gitkeep',
|
|
'media/images/.gitkeep',
|
|
'media/scss/.gitkeep',
|
|
'.editorconfig',
|
|
];
|
|
|
|
$this->log("\nFiles that would be created:", 'INFO');
|
|
foreach ($files as $f) {
|
|
$this->log(" + {$f}", 'INFO');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate the full set of scaffold files for a new template.
|
|
*
|
|
* @return array<string, string> path => content
|
|
*/
|
|
private function getScaffoldFiles(string $name, string $shortName, string $client, string $org): array
|
|
{
|
|
$element = "tpl_{$shortName}";
|
|
$now = date('Y-m-d');
|
|
|
|
$files = [];
|
|
|
|
// templateDetails.xml
|
|
$files['templateDetails.xml'] = <<<XML
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<extension type="template" client="{$client}" method="upgrade">
|
|
<name>{$name}</name>
|
|
<creationDate>{$now}</creationDate>
|
|
<author>Moko Consulting</author>
|
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
|
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
|
<license>GPL-3.0-or-later</license>
|
|
<version>1.0.0</version>
|
|
<description>{$name} — Joomla {$client} template by Moko Consulting</description>
|
|
|
|
<files>
|
|
<filename>index.php</filename>
|
|
<filename>component.php</filename>
|
|
<filename>error.php</filename>
|
|
<filename>offline.php</filename>
|
|
<filename>templateDetails.xml</filename>
|
|
<folder>html</folder>
|
|
<folder>css</folder>
|
|
<folder>js</folder>
|
|
<folder>images</folder>
|
|
<folder>language</folder>
|
|
</files>
|
|
|
|
<media destination="templates/{$client}/{$shortName}" folder="media">
|
|
<folder>css</folder>
|
|
<folder>js</folder>
|
|
<folder>images</folder>
|
|
<folder>scss</folder>
|
|
</media>
|
|
|
|
<positions>
|
|
<position>topbar</position>
|
|
<position>navbar</position>
|
|
<position>hero</position>
|
|
<position>breadcrumbs</position>
|
|
<position>sidebar-left</position>
|
|
<position>sidebar-right</position>
|
|
<position>main-top</position>
|
|
<position>main-bottom</position>
|
|
<position>footer</position>
|
|
<position>debug</position>
|
|
</positions>
|
|
|
|
<updateservers>
|
|
<server type="extension" priority="1" name="{$name} Update Server">
|
|
https://git.mokoconsulting.tech/{$org}/{$name}/raw/branch/main/updates.xml
|
|
</server>
|
|
<server type="extension" priority="2" name="{$name} Update Server">
|
|
https://raw.githubusercontent.com/{$org}/{$name}/main/updates.xml
|
|
</server>
|
|
</updateservers>
|
|
|
|
<config>
|
|
<fields name="params">
|
|
<fieldset name="basic">
|
|
<field name="logoFile" type="media" label="Logo" />
|
|
<field name="siteTitle" type="text" label="Site Title" default="" />
|
|
<field name="siteDescription" type="text" label="Site Description" default="" />
|
|
<field name="colorScheme" type="list" label="Color Scheme" default="light">
|
|
<option value="light">Light</option>
|
|
<option value="dark">Dark</option>
|
|
<option value="auto">Auto (system preference)</option>
|
|
</field>
|
|
</fieldset>
|
|
</fields>
|
|
</config>
|
|
</extension>
|
|
XML;
|
|
$files['templateDetails.xml'] = preg_replace('/^\t\t/m', '', $files['templateDetails.xml']);
|
|
|
|
// updates.xml — dual-platform download URLs (Gitea primary, GitHub secondary)
|
|
$files['updates.xml'] = <<<XML
|
|
<updates>
|
|
<update>
|
|
<name>{$name}</name>
|
|
<description>{$name} — Moko Consulting Joomla template</description>
|
|
<element>tpl_{$shortName}</element>
|
|
<type>template</type>
|
|
<version>1.0.0</version>
|
|
<downloads>
|
|
<downloadurl type="full" format="zip">
|
|
https://git.mokoconsulting.tech/{$org}/{$name}/releases/download/v1.0.0/{$shortName}.zip
|
|
</downloadurl>
|
|
<downloadurl type="full" format="zip">
|
|
https://github.com/{$org}/{$name}/releases/download/v1.0.0/{$shortName}.zip
|
|
</downloadurl>
|
|
</downloads>
|
|
<targetplatform name="joomla" version="[56].*"/>
|
|
<php_minimum>8.1</php_minimum>
|
|
</update>
|
|
</updates>
|
|
XML;
|
|
$files['updates.xml'] = preg_replace('/^\t\t/m', '', $files['updates.xml']);
|
|
|
|
// src/index.php
|
|
$files['src/index.php'] = <<<'PHP'
|
|
<?php
|
|
/**
|
|
* @package Joomla.Site
|
|
* @subpackage Templates.TEMPLATE_SHORT_NAME
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GPL-3.0-or-later
|
|
*/
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
|
|
$app = Factory::getApplication();
|
|
$wa = $this->getWebAssetManager();
|
|
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
|
|
<head>
|
|
<jdoc:include type="metas" />
|
|
<jdoc:include type="styles" />
|
|
<jdoc:include type="scripts" />
|
|
</head>
|
|
<body class="site <?php echo $this->direction === 'rtl' ? 'rtl' : 'ltr'; ?>">
|
|
<header>
|
|
<jdoc:include type="modules" name="topbar" style="none" />
|
|
<jdoc:include type="modules" name="navbar" style="none" />
|
|
</header>
|
|
|
|
<jdoc:include type="modules" name="hero" style="none" />
|
|
<jdoc:include type="modules" name="breadcrumbs" style="none" />
|
|
|
|
<main>
|
|
<jdoc:include type="modules" name="main-top" style="html5" />
|
|
<jdoc:include type="message" />
|
|
<jdoc:include type="component" />
|
|
<jdoc:include type="modules" name="main-bottom" style="html5" />
|
|
</main>
|
|
|
|
<footer>
|
|
<jdoc:include type="modules" name="footer" style="none" />
|
|
</footer>
|
|
|
|
<jdoc:include type="modules" name="debug" style="none" />
|
|
</body>
|
|
</html>
|
|
PHP;
|
|
$files['src/index.php'] = str_replace('TEMPLATE_SHORT_NAME', $shortName, $files['src/index.php']);
|
|
$files['src/index.php'] = preg_replace('/^\t\t/m', '', $files['src/index.php']);
|
|
|
|
// src/error.php
|
|
$files['src/error.php'] = <<<'PHP'
|
|
<?php
|
|
/**
|
|
* @package Joomla.Site
|
|
* @subpackage Templates.error
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GPL-3.0-or-later
|
|
*/
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
/** @var Joomla\CMS\Document\ErrorDocument $this */
|
|
|
|
$code = $this->error->getCode();
|
|
$message = htmlspecialchars($this->error->getMessage(), ENT_QUOTES, 'UTF-8');
|
|
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="<?php echo $this->language; ?>">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title><?php echo $code; ?> — <?php echo $message; ?></title>
|
|
</head>
|
|
<body>
|
|
<div style="max-width:600px;margin:80px auto;text-align:center;font-family:sans-serif">
|
|
<h1><?php echo $code; ?></h1>
|
|
<p><?php echo $message; ?></p>
|
|
<p><a href="<?php echo $this->baseurl; ?>/">Return to homepage</a></p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
PHP;
|
|
$files['src/error.php'] = preg_replace('/^\t\t/m', '', $files['src/error.php']);
|
|
|
|
// src/offline.php
|
|
$files['src/offline.php'] = <<<'PHP'
|
|
<?php
|
|
/**
|
|
* @package Joomla.Site
|
|
* @subpackage Templates.offline
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GPL-3.0-or-later
|
|
*/
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
|
|
$app = Factory::getApplication();
|
|
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="<?php echo $this->language; ?>">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title><?php echo htmlspecialchars($app->get('sitename')); ?> — Maintenance</title>
|
|
</head>
|
|
<body>
|
|
<div style="max-width:600px;margin:80px auto;text-align:center;font-family:sans-serif">
|
|
<h1><?php echo htmlspecialchars($app->get('sitename')); ?></h1>
|
|
<p><?php echo $app->get('offline_message', 'This site is currently undergoing maintenance. Please check back soon.'); ?></p>
|
|
<jdoc:include type="message" />
|
|
<form action="<?php echo $this->baseurl; ?>/index.php" method="post">
|
|
<input type="text" name="username" placeholder="Username" />
|
|
<input type="password" name="password" placeholder="Password" />
|
|
<button type="submit">Login</button>
|
|
<input type="hidden" name="option" value="com_users" />
|
|
<input type="hidden" name="task" value="user.login" />
|
|
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
|
|
</form>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
PHP;
|
|
$files['src/offline.php'] = preg_replace('/^\t\t/m', '', $files['src/offline.php']);
|
|
|
|
// src/component.php
|
|
$files['src/component.php'] = <<<'PHP'
|
|
<?php
|
|
/**
|
|
* @package Joomla.Site
|
|
* @subpackage Templates.component
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GPL-3.0-or-later
|
|
*/
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="<?php echo $this->language; ?>">
|
|
<head>
|
|
<jdoc:include type="head" />
|
|
</head>
|
|
<body class="contentpane">
|
|
<jdoc:include type="message" />
|
|
<jdoc:include type="component" />
|
|
</body>
|
|
</html>
|
|
PHP;
|
|
$files['src/component.php'] = preg_replace('/^\t\t/m', '', $files['src/component.php']);
|
|
|
|
// Directory keepfiles
|
|
$files['src/html/index.html'] = '<!DOCTYPE html><title></title>';
|
|
$files['src/css/.gitkeep'] = '';
|
|
$files['src/js/.gitkeep'] = '';
|
|
$files['src/images/.gitkeep'] = '';
|
|
$files['media/css/.gitkeep'] = '';
|
|
$files['media/js/.gitkeep'] = '';
|
|
$files['media/images/.gitkeep'] = '';
|
|
$files['media/scss/.gitkeep'] = '';
|
|
|
|
// Language files
|
|
$files["src/language/en-GB/{$element}.ini"] = "; {$name} language strings\n";
|
|
$files["src/language/en-GB/{$element}.sys.ini"] =
|
|
"; {$name} system language strings\n"
|
|
. "{$element}=\"{$name}\"\n"
|
|
. "{$element}_XML_DESCRIPTION=\"{$name} Joomla template by Moko Consulting\"\n";
|
|
|
|
// .editorconfig
|
|
$repoRoot = dirname(__DIR__, 2);
|
|
$editorConfig = "{$repoRoot}/templates/configs/.editorconfig";
|
|
if (file_exists($editorConfig)) {
|
|
$files['.editorconfig'] = file_get_contents($editorConfig) ?: '';
|
|
}
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* Get files to sync to existing template repos (standards-only, no template code).
|
|
*
|
|
* @return array<string, string> path => content
|
|
*/
|
|
private function getSyncFiles(string $name, string $shortName): array
|
|
{
|
|
$repoRoot = dirname(__DIR__, 2);
|
|
$files = [];
|
|
|
|
// Sync standards files from templates/
|
|
$standardsFiles = [
|
|
'SECURITY.md' => 'templates/docs/required/template-SECURITY.md',
|
|
'CODE_OF_CONDUCT.md' => 'templates/docs/extra/template-CODE_OF_CONDUCT.md',
|
|
'CONTRIBUTING.md' => 'templates/docs/required/template-CONTRIBUTING.md',
|
|
'.editorconfig' => 'templates/configs/.editorconfig',
|
|
];
|
|
|
|
foreach ($standardsFiles as $dest => $source) {
|
|
$fullPath = "{$repoRoot}/{$source}";
|
|
if (file_exists($fullPath)) {
|
|
$files[$dest] = file_get_contents($fullPath) ?: '';
|
|
}
|
|
}
|
|
|
|
return $files;
|
|
}
|
|
|
|
// ── Sync updates.xml between platforms ───────────────────────────────
|
|
|
|
/**
|
|
* Sync updates.xml (or updates.xml) between Gitea and GitHub for Joomla repos.
|
|
*
|
|
* Reads the file from both platforms, compares by latest <version> tag,
|
|
* and pushes the newer one to the stale platform.
|
|
*
|
|
* Designed to be called from a CI workflow via:
|
|
* php automation/bulk_joomla_template.php --sync-updates --repos=MokoCassiopeia
|
|
*/
|
|
private function syncUpdatesBetweenPlatforms(string $org): int
|
|
{
|
|
$repos = [];
|
|
|
|
if ($this->getArgument('--all', false)) {
|
|
$repos = $this->findTemplateRepos($org);
|
|
// Also include waas-component repos
|
|
$allRepos = $this->adapter->listOrgRepos($org, true);
|
|
foreach ($allRepos as $repo) {
|
|
try {
|
|
$topics = $this->adapter->getRepoTopics($org, $repo['name']);
|
|
if (in_array('joomla', $topics, true) || in_array('joomla-extension', $topics, true)) {
|
|
$repos[] = $repo;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->adapter->getApiClient()->resetCircuitBreaker();
|
|
}
|
|
}
|
|
// Deduplicate
|
|
$seen = [];
|
|
$repos = array_filter($repos, function ($r) use (&$seen) {
|
|
if (isset($seen[$r['name']])) {
|
|
return false;
|
|
}
|
|
$seen[$r['name']] = true;
|
|
return true;
|
|
});
|
|
} else {
|
|
$reposArg = $this->getArgument('--repos', '');
|
|
if (empty($reposArg)) {
|
|
$this->log("❌ --repos or --all required for --sync-updates", 'ERROR');
|
|
return 1;
|
|
}
|
|
$names = array_filter(array_map('trim', explode(',', $reposArg)));
|
|
foreach ($names as $name) {
|
|
$repos[] = ['name' => $name];
|
|
}
|
|
}
|
|
|
|
if (empty($repos)) {
|
|
$this->log("No Joomla repositories to sync updates for", 'INFO');
|
|
return 0;
|
|
}
|
|
|
|
// Create both platform adapters
|
|
try {
|
|
$adapters = PlatformAdapterFactory::createBoth($this->config);
|
|
} catch (\Exception $e) {
|
|
$this->log("❌ Both platform tokens required for --sync-updates: " . $e->getMessage(), 'ERROR');
|
|
return 1;
|
|
}
|
|
|
|
$gitea = $adapters['gitea'];
|
|
$github = $adapters['github'];
|
|
$dryRun = $this->dryRun;
|
|
|
|
$this->log("\nSyncing updates.xml across Gitea <-> GitHub for " . count($repos) . " repo(s)...", 'INFO');
|
|
|
|
$synced = 0;
|
|
$failed = 0;
|
|
|
|
foreach ($repos as $repo) {
|
|
$name = $repo['name'];
|
|
$this->log("\n[{$name}]", 'INFO');
|
|
|
|
// Try both updates.xml and updates.xml filenames
|
|
$updateFile = $this->resolveUpdateFile($gitea, $github, $org, $name);
|
|
if ($updateFile === null) {
|
|
$this->log(" ⊘ No update(s).xml found on either platform", 'INFO');
|
|
continue;
|
|
}
|
|
|
|
$fileName = $updateFile['name'];
|
|
$source = $updateFile['source']; // 'gitea' or 'github'
|
|
$content = $updateFile['content'];
|
|
$target = $source === 'gitea' ? 'github' : 'gitea';
|
|
$targetAdapter = $source === 'gitea' ? $github : $gitea;
|
|
|
|
$this->log(" Source: {$source} ({$fileName})", 'INFO');
|
|
|
|
if ($dryRun) {
|
|
$this->log(" (dry-run) Would push {$fileName} to {$target}", 'INFO');
|
|
$synced++;
|
|
continue;
|
|
}
|
|
|
|
// Push to the other platform
|
|
try {
|
|
$existingSha = null;
|
|
try {
|
|
$existing = $targetAdapter->getFileContents($org, $name, $fileName);
|
|
$existingSha = $existing['sha'] ?? null;
|
|
|
|
// Compare content — skip if identical
|
|
$existingContent = base64_decode($existing['content'] ?? '');
|
|
if (trim($existingContent) === trim($content)) {
|
|
$this->log(" ✓ Already in sync", 'INFO');
|
|
$synced++;
|
|
continue;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$targetAdapter->getApiClient()->resetCircuitBreaker();
|
|
}
|
|
|
|
$targetAdapter->createOrUpdateFile(
|
|
$org,
|
|
$name,
|
|
$fileName,
|
|
$content,
|
|
"chore: sync {$fileName} from {$source}",
|
|
$existingSha
|
|
);
|
|
$this->log(" ✓ Pushed to {$target}", 'INFO');
|
|
$synced++;
|
|
} catch (\Exception $e) {
|
|
$this->log(" ✗ Failed to push to {$target}: " . $e->getMessage(), 'ERROR');
|
|
$targetAdapter->getApiClient()->resetCircuitBreaker();
|
|
$failed++;
|
|
}
|
|
}
|
|
|
|
$this->log("\n" . str_repeat('=', 50), 'INFO');
|
|
$this->log("Updates sync complete: {$synced} synced, {$failed} failed", 'INFO');
|
|
|
|
return $failed > 0 ? 1 : 0;
|
|
}
|
|
|
|
/**
|
|
* Find the updates file on both platforms, return the one with the higher version.
|
|
*
|
|
* Checks both `updates.xml` and `updates.xml` filenames.
|
|
* Returns the content from the platform with the newer <version>.
|
|
* Gitea wins ties (primary platform).
|
|
*
|
|
* @return array{name: string, source: string, content: string}|null
|
|
*/
|
|
private function resolveUpdateFile(
|
|
GitPlatformAdapter $gitea,
|
|
GitPlatformAdapter $github,
|
|
string $org,
|
|
string $name
|
|
): ?array {
|
|
$candidates = ['updates.xml', 'updates.xml'];
|
|
$found = []; // platform => [name, content, version]
|
|
|
|
foreach (['gitea' => $gitea, 'github' => $github] as $platform => $adapter) {
|
|
foreach ($candidates as $fileName) {
|
|
try {
|
|
$file = $adapter->getFileContents($org, $name, $fileName);
|
|
$content = base64_decode($file['content'] ?? '');
|
|
|
|
// Extract latest version from the XML
|
|
$version = '0.0.0';
|
|
if (preg_match('/<version>([^<]+)<\/version>/', $content, $m)) {
|
|
$version = trim($m[1]);
|
|
}
|
|
|
|
$found[$platform] = [
|
|
'name' => $fileName,
|
|
'content' => $content,
|
|
'version' => $version,
|
|
];
|
|
break; // Found one — stop checking other filenames for this platform
|
|
} catch (\Exception $e) {
|
|
$adapter->getApiClient()->resetCircuitBreaker();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($found)) {
|
|
return null;
|
|
}
|
|
|
|
// If only one platform has it, that's the source
|
|
if (count($found) === 1) {
|
|
$platform = array_key_first($found);
|
|
return [
|
|
'name' => $found[$platform]['name'],
|
|
'source' => $platform,
|
|
'content' => $found[$platform]['content'],
|
|
];
|
|
}
|
|
|
|
// Both have it — pick the one with the higher version (Gitea wins ties)
|
|
$giteaVer = $found['gitea']['version'];
|
|
$githubVer = $found['github']['version'];
|
|
|
|
$source = version_compare($githubVer, $giteaVer, '>') ? 'github' : 'gitea';
|
|
|
|
return [
|
|
'name' => $found[$source]['name'],
|
|
'source' => $source,
|
|
'content' => $found[$source]['content'],
|
|
];
|
|
}
|
|
}
|
|
|
|
// Execute if run directly
|
|
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
|
|
$app = new BulkJoomlaTemplate();
|
|
exit($app->execute());
|
|
}
|