Improve Joomla development workflows and convert scripts to Python #31
BIN
scripts/lib/__pycache__/common.cpython-312.pyc
Normal file
BIN
scripts/lib/__pycache__/joomla_manifest.cpython-312.pyc
Normal file
452
scripts/lib/common.py
Executable file
@@ -0,0 +1,452 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Common utilities for Moko-Cassiopeia scripts.
|
||||||
|
|
||||||
|
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
|
||||||
|
This file is part of a Moko Consulting project.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program (./LICENSE.md).
|
||||||
|
|
||||||
|
FILE INFORMATION
|
||||||
|
DEFGROUP: Script.Library
|
||||||
|
INGROUP: Common
|
||||||
|
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||||
|
PATH: /scripts/lib/common.py
|
||||||
|
VERSION: 01.00.00
|
||||||
|
BRIEF: Unified shared Python utilities for all CI and local scripts
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
import subprocess
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Environment and Detection
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def is_ci() -> bool:
|
||||||
|
"""Check if running in CI environment."""
|
||||||
|
return os.environ.get("CI", "").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def require_cmd(command: str) -> None:
|
||||||
|
"""
|
||||||
|
Ensure a required command is available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Command name to check
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If command is not found
|
||||||
|
"""
|
||||||
|
if not shutil.which(command):
|
||||||
|
log_error(f"Required command not found: {command}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Logging
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class Colors:
|
||||||
|
"""ANSI color codes for terminal output."""
|
||||||
|
RED = '\033[0;31m'
|
||||||
|
GREEN = '\033[0;32m'
|
||||||
|
YELLOW = '\033[1;33m'
|
||||||
|
BLUE = '\033[0;34m'
|
||||||
|
CYAN = '\033[0;36m'
|
||||||
|
BOLD = '\033[1m'
|
||||||
|
NC = '\033[0m' # No Color
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled(cls) -> bool:
|
||||||
|
"""Check if colors should be enabled."""
|
||||||
|
return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
||||||
|
|
||||||
|
|
||||||
|
def log_info(message: str) -> None:
|
||||||
|
"""Log informational message."""
|
||||||
|
print(f"INFO: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_warn(message: str) -> None:
|
||||||
|
"""Log warning message."""
|
||||||
|
color = Colors.YELLOW if Colors.enabled() else ""
|
||||||
|
nc = Colors.NC if Colors.enabled() else ""
|
||||||
|
print(f"{color}WARN: {message}{nc}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def log_error(message: str) -> None:
|
||||||
|
"""Log error message."""
|
||||||
|
color = Colors.RED if Colors.enabled() else ""
|
||||||
|
nc = Colors.NC if Colors.enabled() else ""
|
||||||
|
print(f"{color}ERROR: {message}{nc}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def log_success(message: str) -> None:
|
||||||
|
"""Log success message."""
|
||||||
|
color = Colors.GREEN if Colors.enabled() else ""
|
||||||
|
nc = Colors.NC if Colors.enabled() else ""
|
||||||
|
print(f"{color}✓ {message}{nc}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_step(message: str) -> None:
|
||||||
|
"""Log a step in a process."""
|
||||||
|
color = Colors.CYAN if Colors.enabled() else ""
|
||||||
|
nc = Colors.NC if Colors.enabled() else ""
|
||||||
|
print(f"{color}➜ {message}{nc}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_section(title: str) -> None:
|
||||||
|
"""Log a section header."""
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print(title)
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def log_kv(key: str, value: str) -> None:
|
||||||
|
"""Log a key-value pair."""
|
||||||
|
print(f" {key}: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
def die(message: str, exit_code: int = 1) -> None:
|
||||||
|
"""
|
||||||
|
Log error and exit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Error message
|
||||||
|
exit_code: Exit code (default: 1)
|
||||||
|
"""
|
||||||
|
log_error(message)
|
||||||
|
|
||||||
|
if os.environ.get("VERBOSE_ERRORS", "true").lower() == "true":
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print("Stack trace:", file=sys.stderr)
|
||||||
|
traceback.print_stack(file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print("Environment:", file=sys.stderr)
|
||||||
|
print(f" PWD: {os.getcwd()}", file=sys.stderr)
|
||||||
|
print(f" USER: {os.environ.get('USER', 'unknown')}", file=sys.stderr)
|
||||||
|
print(f" PYTHON: {sys.version}", file=sys.stderr)
|
||||||
|
print(f" CI: {is_ci()}", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
|||||||
|
|
||||||
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Validation Helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def assert_file_exists(path: Union[str, Path]) -> None:
|
||||||
|
"""
|
||||||
|
Assert that a file exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to file
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If file doesn't exist
|
||||||
|
"""
|
||||||
|
if not Path(path).is_file():
|
||||||
|
die(f"Required file missing: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_dir_exists(path: Union[str, Path]) -> None:
|
||||||
|
"""
|
||||||
|
Assert that a directory exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to directory
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If directory doesn't exist
|
||||||
|
"""
|
||||||
|
if not Path(path).is_dir():
|
||||||
|
die(f"Required directory missing: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_not_empty(value: Any, name: str) -> None:
|
||||||
|
"""
|
||||||
|
Assert that a value is not empty.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Value to check
|
||||||
|
name: Name of the value for error message
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If value is empty
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
die(f"Required value is empty: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# JSON Utilities
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def json_escape(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Escape text for JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to escape
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Escaped text
|
||||||
|
"""
|
||||||
|
return json.dumps(text)[1:-1] # Remove surrounding quotes
|
||||||
|
|
||||||
|
|
||||||
|
def json_output(data: Dict[str, Any], pretty: bool = False) -> None:
|
||||||
|
"""
|
||||||
|
Output data as JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary to output
|
||||||
|
pretty: Whether to pretty-print
|
||||||
|
"""
|
||||||
|
if pretty:
|
||||||
|
print(json.dumps(data, indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
print(json.dumps(data, separators=(',', ':')))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Path Utilities
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def script_root() -> Path:
|
||||||
|
"""
|
||||||
|
Get the root scripts directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to scripts directory
|
||||||
|
"""
|
||||||
|
return Path(__file__).parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def repo_root() -> Path:
|
||||||
|
"""
|
||||||
|
Get the repository root directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to repository root
|
||||||
|
"""
|
||||||
|
return script_root().parent
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path(path: Union[str, Path]) -> str:
|
||||||
|
"""
|
||||||
|
Normalize a path (resolve, absolute, forward slashes).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to normalize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized path string
|
||||||
|
"""
|
||||||
|
return str(Path(path).resolve()).replace("\\", "/")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# File Operations
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def read_file(path: Union[str, Path], encoding: str = "utf-8") -> str:
|
||||||
|
"""
|
||||||
|
Read a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to file
|
||||||
|
encoding: File encoding
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File contents
|
||||||
|
"""
|
||||||
|
assert_file_exists(path)
|
||||||
|
return Path(path).read_text(encoding=encoding)
|
||||||
|
|
||||||
|
|
||||||
|
def write_file(path: Union[str, Path], content: str, encoding: str = "utf-8") -> None:
|
||||||
|
"""
|
||||||
|
Write a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to file
|
||||||
|
content: Content to write
|
||||||
|
encoding: File encoding
|
||||||
|
"""
|
||||||
|
Path(path).write_text(content, encoding=encoding)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path: Union[str, Path]) -> None:
|
||||||
|
"""
|
||||||
|
Ensure a directory exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to directory
|
||||||
|
"""
|
||||||
|
Path(path).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Command Execution
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def run_command(
|
||||||
|
cmd: List[str],
|
||||||
|
capture_output: bool = True,
|
||||||
|
check: bool = True,
|
||||||
|
cwd: Optional[Union[str, Path]] = None,
|
||||||
|
env: Optional[Dict[str, str]] = None
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
"""
|
||||||
|
Run a command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmd: Command and arguments
|
||||||
|
capture_output: Whether to capture stdout/stderr
|
||||||
|
check: Whether to raise on non-zero exit
|
||||||
|
cwd: Working directory
|
||||||
|
env: Environment variables
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CompletedProcess instance
|
||||||
|
"""
|
||||||
|
return subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=capture_output,
|
||||||
|
text=True,
|
||||||
|
check=check,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_shell(
|
||||||
|
script: str,
|
||||||
|
capture_output: bool = True,
|
||||||
|
check: bool = True,
|
||||||
|
cwd: Optional[Union[str, Path]] = None
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
"""
|
||||||
|
Run a shell script.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
script: Shell script
|
||||||
|
capture_output: Whether to capture stdout/stderr
|
||||||
|
check: Whether to raise on non-zero exit
|
||||||
|
cwd: Working directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CompletedProcess instance
|
||||||
|
"""
|
||||||
|
return subprocess.run(
|
||||||
|
script,
|
||||||
|
shell=True,
|
||||||
|
capture_output=capture_output,
|
||||||
|
text=True,
|
||||||
|
check=check,
|
||||||
|
cwd=cwd
|
||||||
|
)
|
||||||
|
The 'run_shell' function executes shell commands with 'shell=True', which can be a security risk if the 'script' parameter contains untrusted input. While this is a utility function, consider adding a docstring warning about the security implications and recommending the use of 'run_command' with a list of arguments instead when possible. The 'run_shell' function executes shell commands with 'shell=True', which can be a security risk if the 'script' parameter contains untrusted input. While this is a utility function, consider adding a docstring warning about the security implications and recommending the use of 'run_command' with a list of arguments instead when possible.
|
|||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Git Utilities
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def git_root() -> Path:
|
||||||
|
"""
|
||||||
|
Get git repository root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to git root
|
||||||
|
"""
|
||||||
|
result = run_command(
|
||||||
|
["git", "rev-parse", "--show-toplevel"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
return Path(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def git_status(porcelain: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
Get git status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
porcelain: Use porcelain format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Git status output
|
||||||
|
"""
|
||||||
|
cmd = ["git", "status"]
|
||||||
|
if porcelain:
|
||||||
|
cmd.append("--porcelain")
|
||||||
|
|
||||||
|
result = run_command(cmd, capture_output=True, check=True)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def git_branch() -> str:
|
||||||
|
"""
|
||||||
|
Get current git branch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Branch name
|
||||||
|
"""
|
||||||
|
result = run_command(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Main Entry Point (for testing)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Test the common utilities."""
|
||||||
|
log_section("Testing Common Utilities")
|
||||||
|
|
||||||
|
log_info("This is an info message")
|
||||||
|
log_warn("This is a warning message")
|
||||||
|
log_success("This is a success message")
|
||||||
|
log_step("This is a step message")
|
||||||
|
|
||||||
|
log_section("Environment")
|
||||||
|
log_kv("CI", str(is_ci()))
|
||||||
|
log_kv("Script Root", str(script_root()))
|
||||||
|
log_kv("Repo Root", str(repo_root()))
|
||||||
|
log_kv("Git Root", str(git_root()))
|
||||||
|
log_kv("Git Branch", git_branch())
|
||||||
|
|
||||||
|
log_section("Tests Passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
430
scripts/lib/joomla_manifest.py
Executable file
@@ -0,0 +1,430 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Joomla manifest parsing and validation utilities.
|
||||||
|
|
||||||
|
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
|
||||||
|
This file is part of a Moko Consulting project.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program (./LICENSE.md).
|
||||||
|
|
||||||
|
FILE INFORMATION
|
||||||
|
DEFGROUP: Script.Library
|
||||||
|
INGROUP: Joomla.Manifest
|
||||||
|
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||||
|
PATH: /scripts/lib/joomla_manifest.py
|
||||||
|
VERSION: 01.00.00
|
||||||
|
BRIEF: Joomla manifest parsing and validation utilities
|
||||||
|
"""
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from . import common
|
||||||
|
except ImportError:
|
||||||
|
import common
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Joomla Extension Types
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class ExtensionType:
|
||||||
|
"""Joomla extension types."""
|
||||||
|
COMPONENT = "component"
|
||||||
|
MODULE = "module"
|
||||||
|
PLUGIN = "plugin"
|
||||||
|
TEMPLATE = "template"
|
||||||
|
LIBRARY = "library"
|
||||||
|
PACKAGE = "package"
|
||||||
|
FILE = "file"
|
||||||
|
LANGUAGE = "language"
|
||||||
|
|
||||||
|
ALL_TYPES = [
|
||||||
|
COMPONENT, MODULE, PLUGIN, TEMPLATE,
|
||||||
|
LIBRARY, PACKAGE, FILE, LANGUAGE
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Manifest Data Class
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ManifestInfo:
|
||||||
|
"""Information extracted from a Joomla manifest."""
|
||||||
|
path: Path
|
||||||
|
extension_type: str
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
author: Optional[str] = None
|
||||||
|
author_email: Optional[str] = None
|
||||||
|
author_url: Optional[str] = None
|
||||||
|
copyright: Optional[str] = None
|
||||||
|
license: Optional[str] = None
|
||||||
|
creation_date: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, str]:
|
||||||
|
"""Convert to dictionary."""
|
||||||
|
return {
|
||||||
|
"path": str(self.path),
|
||||||
|
"ext_type": self.extension_type,
|
||||||
|
"name": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"description": self.description or "",
|
||||||
|
"author": self.author or "",
|
||||||
|
"author_email": self.author_email or "",
|
||||||
|
"author_url": self.author_url or "",
|
||||||
|
"copyright": self.copyright or "",
|
||||||
|
"license": self.license or "",
|
||||||
|
"creation_date": self.creation_date or ""
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Manifest Discovery
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def find_manifest(src_dir: str = "src") -> Path:
|
||||||
|
"""
|
||||||
|
Find the primary Joomla manifest in the given directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
src_dir: Source directory to search
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to manifest file
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If no manifest found
|
||||||
|
"""
|
||||||
|
src_path = Path(src_dir)
|
||||||
|
|
||||||
|
if not src_path.is_dir():
|
||||||
|
common.die(f"Source directory missing: {src_dir}")
|
||||||
|
|
||||||
|
# Template manifest (templateDetails.xml)
|
||||||
|
template_manifest = src_path / "templateDetails.xml"
|
||||||
|
if template_manifest.is_file():
|
||||||
|
return template_manifest
|
||||||
|
|
||||||
|
# Also check in templates subdirectory
|
||||||
|
templates_dir = src_path / "templates"
|
||||||
|
if templates_dir.is_dir():
|
||||||
|
for template_file in templates_dir.glob("templateDetails.xml"):
|
||||||
|
return template_file
|
||||||
|
|
||||||
|
# Package manifest (pkg_*.xml)
|
||||||
|
pkg_manifests = list(src_path.rglob("pkg_*.xml"))
|
||||||
|
if pkg_manifests:
|
||||||
|
return pkg_manifests[0]
|
||||||
|
|
||||||
|
# Component manifest (com_*.xml)
|
||||||
|
com_manifests = list(src_path.rglob("com_*.xml"))
|
||||||
|
if com_manifests:
|
||||||
|
return com_manifests[0]
|
||||||
|
|
||||||
|
# Module manifest (mod_*.xml)
|
||||||
|
mod_manifests = list(src_path.rglob("mod_*.xml"))
|
||||||
|
if mod_manifests:
|
||||||
|
return mod_manifests[0]
|
||||||
|
|
||||||
|
# Plugin manifest (plg_*.xml)
|
||||||
|
plg_manifests = list(src_path.rglob("plg_*.xml"))
|
||||||
|
if plg_manifests:
|
||||||
|
return plg_manifests[0]
|
||||||
|
|
||||||
|
# Fallback: any XML with <extension
|
||||||
|
for xml_file in src_path.rglob("*.xml"):
|
||||||
|
try:
|
||||||
|
content = xml_file.read_text(encoding="utf-8")
|
||||||
|
if "<extension" in content:
|
||||||
|
return xml_file
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
common.die(f"No Joomla manifest XML found under {src_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
def find_all_manifests(src_dir: str = "src") -> List[Path]:
|
||||||
|
"""
|
||||||
|
Find all Joomla manifests in the given directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
src_dir: Source directory to search
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of manifest paths
|
||||||
|
"""
|
||||||
|
src_path = Path(src_dir)
|
||||||
|
|
||||||
|
if not src_path.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
manifests = []
|
||||||
|
|
||||||
|
# Template manifests
|
||||||
|
manifests.extend(src_path.rglob("templateDetails.xml"))
|
||||||
|
|
||||||
|
# Package manifests
|
||||||
|
manifests.extend(src_path.rglob("pkg_*.xml"))
|
||||||
|
|
||||||
|
# Component manifests
|
||||||
|
manifests.extend(src_path.rglob("com_*.xml"))
|
||||||
|
|
||||||
|
# Module manifests
|
||||||
|
manifests.extend(src_path.rglob("mod_*.xml"))
|
||||||
|
|
||||||
|
# Plugin manifests
|
||||||
|
manifests.extend(src_path.rglob("plg_*.xml"))
|
||||||
|
|
||||||
|
return manifests
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Manifest Parsing
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def parse_manifest(manifest_path: Path) -> ManifestInfo:
|
||||||
|
"""
|
||||||
|
Parse a Joomla manifest file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest_path: Path to manifest file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ManifestInfo object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If parsing fails
|
||||||
|
"""
|
||||||
|
if not manifest_path.is_file():
|
||||||
|
common.die(f"Manifest not found: {manifest_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ET.parse(manifest_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
The XML parsing doesn't handle the case where the root element is not 'extension'. For Joomla manifests, the root element should always be 'extension', but if it's not, the code will proceed with potentially invalid data. Consider adding a validation check that the root element tag is 'extension' and raise a clear error if it's not. The XML parsing doesn't handle the case where the root element is not 'extension'. For Joomla manifests, the root element should always be 'extension', but if it's not, the code will proceed with potentially invalid data. Consider adding a validation check that the root element tag is 'extension' and raise a clear error if it's not.
```suggestion
root = tree.getroot()
# Validate root element
if root.tag != "extension":
common.die(
f"Invalid Joomla manifest root element '{root.tag}'; "
f"expected 'extension': {manifest_path}"
)
```
|
|||||||
|
|
||||||
|
# Extract extension type
|
||||||
|
ext_type = root.attrib.get("type", "").strip().lower()
|
||||||
|
if not ext_type:
|
||||||
|
common.die(f"Manifest missing type attribute: {manifest_path}")
|
||||||
|
|
||||||
|
# Extract name
|
||||||
|
name_elem = root.find("name")
|
||||||
|
if name_elem is None or not name_elem.text:
|
||||||
|
common.die(f"Manifest missing name element: {manifest_path}")
|
||||||
|
name = name_elem.text.strip()
|
||||||
|
|
||||||
|
# Extract version
|
||||||
|
version_elem = root.find("version")
|
||||||
|
if version_elem is None or not version_elem.text:
|
||||||
|
common.die(f"Manifest missing version element: {manifest_path}")
|
||||||
|
version = version_elem.text.strip()
|
||||||
|
|
||||||
|
# Extract optional fields
|
||||||
|
description = None
|
||||||
|
desc_elem = root.find("description")
|
||||||
|
if desc_elem is not None and desc_elem.text:
|
||||||
|
description = desc_elem.text.strip()
|
||||||
|
|
||||||
|
author = None
|
||||||
|
author_elem = root.find("author")
|
||||||
|
if author_elem is not None and author_elem.text:
|
||||||
|
author = author_elem.text.strip()
|
||||||
|
|
||||||
|
author_email = None
|
||||||
|
email_elem = root.find("authorEmail")
|
||||||
|
if email_elem is not None and email_elem.text:
|
||||||
|
author_email = email_elem.text.strip()
|
||||||
|
|
||||||
|
author_url = None
|
||||||
|
url_elem = root.find("authorUrl")
|
||||||
|
if url_elem is not None and url_elem.text:
|
||||||
|
author_url = url_elem.text.strip()
|
||||||
|
|
||||||
|
copyright_text = None
|
||||||
|
copyright_elem = root.find("copyright")
|
||||||
|
if copyright_elem is not None and copyright_elem.text:
|
||||||
|
copyright_text = copyright_elem.text.strip()
|
||||||
|
|
||||||
|
license_text = None
|
||||||
|
license_elem = root.find("license")
|
||||||
|
if license_elem is not None and license_elem.text:
|
||||||
|
license_text = license_elem.text.strip()
|
||||||
|
|
||||||
|
creation_date = None
|
||||||
|
date_elem = root.find("creationDate")
|
||||||
|
if date_elem is not None and date_elem.text:
|
||||||
|
creation_date = date_elem.text.strip()
|
||||||
|
|
||||||
|
return ManifestInfo(
|
||||||
|
path=manifest_path,
|
||||||
|
extension_type=ext_type,
|
||||||
|
name=name,
|
||||||
|
version=version,
|
||||||
|
description=description,
|
||||||
|
author=author,
|
||||||
|
author_email=author_email,
|
||||||
|
author_url=author_url,
|
||||||
|
copyright=copyright_text,
|
||||||
|
license=license_text,
|
||||||
|
creation_date=creation_date
|
||||||
|
)
|
||||||
|
|
||||||
|
except ET.ParseError as e:
|
||||||
|
common.die(f"Failed to parse manifest {manifest_path}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
common.die(f"Error reading manifest {manifest_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest_version(manifest_path: Path) -> str:
|
||||||
|
"""
|
||||||
|
Extract version from manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest_path: Path to manifest file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Version string
|
||||||
|
"""
|
||||||
|
info = parse_manifest(manifest_path)
|
||||||
|
return info.version
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest_name(manifest_path: Path) -> str:
|
||||||
|
"""
|
||||||
|
Extract name from manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest_path: Path to manifest file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Name string
|
||||||
|
"""
|
||||||
|
info = parse_manifest(manifest_path)
|
||||||
|
return info.name
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest_type(manifest_path: Path) -> str:
|
||||||
|
"""
|
||||||
|
Extract extension type from manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest_path: Path to manifest file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Extension type string
|
||||||
|
"""
|
||||||
|
info = parse_manifest(manifest_path)
|
||||||
|
return info.extension_type
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Manifest Validation
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def validate_manifest(manifest_path: Path) -> Tuple[bool, List[str]]:
|
||||||
|
"""
|
||||||
|
Validate a Joomla manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest_path: Path to manifest file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, list_of_warnings)
|
||||||
|
"""
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = parse_manifest(manifest_path)
|
||||||
|
|
||||||
|
# Check for recommended fields
|
||||||
|
if not info.description:
|
||||||
|
warnings.append("Missing description element")
|
||||||
|
|
||||||
|
if not info.author:
|
||||||
|
warnings.append("Missing author element")
|
||||||
|
|
||||||
|
if not info.copyright:
|
||||||
|
warnings.append("Missing copyright element")
|
||||||
|
|
||||||
|
if not info.license:
|
||||||
|
warnings.append("Missing license element")
|
||||||
|
|
||||||
|
if not info.creation_date:
|
||||||
|
warnings.append("Missing creationDate element")
|
||||||
|
|
||||||
|
# Validate extension type
|
||||||
|
if info.extension_type not in ExtensionType.ALL_TYPES:
|
||||||
|
warnings.append(f"Unknown extension type: {info.extension_type}")
|
||||||
|
|
||||||
|
return (True, warnings)
|
||||||
|
|
||||||
|
except SystemExit:
|
||||||
|
return (False, ["Failed to parse manifest"])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Main Entry Point (for testing)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Test the manifest utilities."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
common.log_section("Testing Joomla Manifest Utilities")
|
||||||
|
|
||||||
|
src_dir = sys.argv[1] if len(sys.argv) > 1 else "src"
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = find_manifest(src_dir)
|
||||||
|
common.log_success(f"Found manifest: {manifest}")
|
||||||
|
|
||||||
|
info = parse_manifest(manifest)
|
||||||
|
|
||||||
|
common.log_section("Manifest Information")
|
||||||
|
common.log_kv("Type", info.extension_type)
|
||||||
|
common.log_kv("Name", info.name)
|
||||||
|
common.log_kv("Version", info.version)
|
||||||
|
|
||||||
|
if info.description:
|
||||||
|
common.log_kv("Description", info.description[:60] + "..." if len(info.description) > 60 else info.description)
|
||||||
|
|
||||||
|
if info.author:
|
||||||
|
common.log_kv("Author", info.author)
|
||||||
|
|
||||||
|
is_valid, warnings = validate_manifest(manifest)
|
||||||
|
|
||||||
|
if is_valid:
|
||||||
|
common.log_success("Manifest is valid")
|
||||||
|
if warnings:
|
||||||
|
common.log_warn(f"Warnings: {len(warnings)}")
|
||||||
|
for warning in warnings:
|
||||||
|
print(f" - {warning}")
|
||||||
|
else:
|
||||||
|
common.log_error("Manifest validation failed")
|
||||||
|
|
||||||
|
except SystemExit as e:
|
||||||
|
sys.exit(e.code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
448
scripts/run/scaffold_extension.py
Executable file
@@ -0,0 +1,448 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Create Joomla extension scaffolding.
|
||||||
|
|
||||||
|
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
|
||||||
|
This file is part of a Moko Consulting project.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program (./LICENSE.md).
|
||||||
|
|
||||||
|
FILE INFORMATION
|
||||||
|
DEFGROUP: Moko-Cassiopeia.Scripts
|
||||||
|
INGROUP: Scripts.Run
|
||||||
|
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||||
|
PATH: /scripts/run/scaffold_extension.py
|
||||||
|
VERSION: 01.00.00
|
||||||
|
BRIEF: Create scaffolding for different Joomla extension types
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
# Add lib directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
import common
|
||||||
|
import joomla_manifest
|
||||||
|
Import of 'joomla_manifest' is not used. Import of 'joomla_manifest' is not used.
```suggestion
```
|
|||||||
|
except ImportError:
|
||||||
|
print("ERROR: Cannot import required libraries", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Templates for Extension Scaffolding
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def get_component_structure(name: str, description: str, author: str) -> Dict[str, str]:
|
||||||
|
"""Get directory structure and files for a component."""
|
||||||
|
safe_name = name.lower().replace(" ", "_")
|
||||||
|
com_name = f"com_{safe_name}"
|
||||||
|
|
||||||
|
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<extension type="component" version="4.0" method="upgrade">
|
||||||
|
<name>{name}</name>
|
||||||
|
<author>{author}</author>
|
||||||
|
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||||
|
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||||
|
<license>GPL-3.0-or-later</license>
|
||||||
|
<authorEmail>hello@example.com</authorEmail>
|
||||||
|
<authorUrl>https://example.com</authorUrl>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<description>{description}</description>
|
||||||
|
|
||||||
|
<files folder="site">
|
||||||
|
<folder>src</folder>
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<administration>
|
||||||
|
<menu>{name}</menu>
|
||||||
|
<files folder="admin">
|
||||||
|
<folder>services</folder>
|
||||||
|
<folder>sql</folder>
|
||||||
|
<folder>src</folder>
|
||||||
|
</files>
|
||||||
|
</administration>
|
||||||
|
</extension>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
f"{com_name}.xml": manifest,
|
||||||
|
"site/src/.gitkeep": "",
|
||||||
|
"admin/services/provider.php": f"<?php\n// Service provider for {name}\n",
|
||||||
|
"admin/sql/install.mysql.utf8.sql": "-- Installation SQL\n",
|
||||||
|
"admin/sql/uninstall.mysql.utf8.sql": "-- Uninstallation SQL\n",
|
||||||
|
"admin/src/.gitkeep": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_module_structure(name: str, description: str, author: str, client: str = "site") -> Dict[str, str]:
|
||||||
|
"""Get directory structure and files for a module."""
|
||||||
|
safe_name = name.lower().replace(" ", "_")
|
||||||
|
mod_name = f"mod_{safe_name}"
|
||||||
|
|
||||||
|
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<extension type="module" version="4.0" client="{client}" method="upgrade">
|
||||||
|
<name>{name}</name>
|
||||||
|
<author>{author}</author>
|
||||||
|
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||||
|
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||||
|
<license>GPL-3.0-or-later</license>
|
||||||
|
<authorEmail>hello@example.com</authorEmail>
|
||||||
|
<authorUrl>https://example.com</authorUrl>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<description>{description}</description>
|
||||||
|
|
||||||
|
<files>
|
||||||
|
<filename module="{mod_name}">{mod_name}.php</filename>
|
||||||
|
<filename>{mod_name}.xml</filename>
|
||||||
|
<folder>tmpl</folder>
|
||||||
|
</files>
|
||||||
|
</extension>
|
||||||
|
"""
|
||||||
|
|
||||||
|
module_php = f"""<?php
|
||||||
|
/**
|
||||||
|
* @package {name}
|
||||||
|
* @copyright Copyright (C) {datetime.now().year} {author}
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
// Module logic here
|
||||||
|
require JModuleHelper::getLayoutPath('mod_{safe_name}', $params->get('layout', 'default'));
|
||||||
|
"""
|
||||||
|
|
||||||
|
default_tmpl = f"""<?php
|
||||||
|
/**
|
||||||
|
* @package {name}
|
||||||
|
* @copyright Copyright (C) {datetime.now().year} {author}
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
?>
|
||||||
|
<div class="{mod_name}">
|
||||||
|
<p><?php echo JText::_('MOD_{safe_name.upper()}_DESCRIPTION'); ?></p>
|
||||||
|
The f-string contains an embedded f-string for 'safe_name.upper()' on line 144. This creates a nested f-string pattern which is unnecessarily complex and reduces readability. Consider computing 'safe_name.upper()' beforehand and using it in the f-string, or restructuring the string formatting. The f-string contains an embedded f-string for 'safe_name.upper()' on line 144. This creates a nested f-string pattern which is unnecessarily complex and reduces readability. Consider computing 'safe_name.upper()' beforehand and using it in the f-string, or restructuring the string formatting.
|
|||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
f"{mod_name}.xml": manifest,
|
||||||
|
f"{mod_name}.php": module_php,
|
||||||
|
"tmpl/default.php": default_tmpl,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_structure(name: str, description: str, author: str, group: str = "system") -> Dict[str, str]:
|
||||||
|
"""Get directory structure and files for a plugin."""
|
||||||
|
safe_name = name.lower().replace(" ", "_")
|
||||||
|
plg_name = f"{safe_name}"
|
||||||
|
|
||||||
|
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<extension type="plugin" version="4.0" group="{group}" method="upgrade">
|
||||||
|
<name>plg_{group}_{safe_name}</name>
|
||||||
|
<author>{author}</author>
|
||||||
|
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||||
|
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||||
|
<license>GPL-3.0-or-later</license>
|
||||||
|
<authorEmail>hello@example.com</authorEmail>
|
||||||
|
<authorUrl>https://example.com</authorUrl>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<description>{description}</description>
|
||||||
|
|
||||||
|
<files>
|
||||||
|
<filename plugin="{plg_name}">{plg_name}.php</filename>
|
||||||
|
</files>
|
||||||
|
</extension>
|
||||||
|
"""
|
||||||
|
|
||||||
|
plugin_php = f"""<?php
|
||||||
|
/**
|
||||||
|
* @package {name}
|
||||||
|
* @copyright Copyright (C) {datetime.now().year} {author}
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\\CMS\\Plugin\\CMSPlugin;
|
||||||
|
|
||||||
|
class Plg{group.capitalize()}{plg_name.capitalize()} extends CMSPlugin
|
||||||
|
The plugin PHP class name generation uses 'plg_name.capitalize()' which only capitalizes the first character. For multi-word plugin names (e.g., 'my_plugin'), this would generate 'PlgSystemMy_plugin' instead of 'PlgSystemMyPlugin'. Consider using a proper camelCase conversion that capitalizes each word segment separated by underscores. The plugin PHP class name generation uses 'plg_name.capitalize()' which only capitalizes the first character. For multi-word plugin names (e.g., 'my_plugin'), this would generate 'PlgSystemMy_plugin' instead of 'PlgSystemMyPlugin'. Consider using a proper camelCase conversion that capitalizes each word segment separated by underscores.
|
|||||||
|
{{
|
||||||
|
protected $autoloadLanguage = true;
|
||||||
|
|
||||||
|
public function onContentPrepare($context, &$article, &$params, $limitstart = 0)
|
||||||
|
{{
|
||||||
|
// Plugin logic here
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
f"plg_{group}_{safe_name}.xml": manifest,
|
||||||
|
f"{plg_name}.php": plugin_php,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_template_structure(name: str, description: str, author: str) -> Dict[str, str]:
|
||||||
|
"""Get directory structure and files for a template."""
|
||||||
|
safe_name = name.lower().replace(" ", "_")
|
||||||
|
|
||||||
|
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<extension type="template" version="4.0" client="site" method="upgrade">
|
||||||
|
<name>{safe_name}</name>
|
||||||
|
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||||
|
<author>{author}</author>
|
||||||
|
<authorEmail>hello@example.com</authorEmail>
|
||||||
|
<authorUrl>https://example.com</authorUrl>
|
||||||
|
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||||
|
<license>GPL-3.0-or-later</license>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<description>{description}</description>
|
||||||
|
|
||||||
|
<files>
|
||||||
|
<filename>index.php</filename>
|
||||||
|
<filename>templateDetails.xml</filename>
|
||||||
|
<folder>css</folder>
|
||||||
|
<folder>js</folder>
|
||||||
|
<folder>images</folder>
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<positions>
|
||||||
|
<position>header</position>
|
||||||
|
<position>main</position>
|
||||||
|
<position>footer</position>
|
||||||
|
</positions>
|
||||||
|
</extension>
|
||||||
|
"""
|
||||||
|
|
||||||
|
index_php = f"""<?php
|
||||||
|
/**
|
||||||
|
* @package {name}
|
||||||
|
* @copyright Copyright (C) {datetime.now().year} {author}
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\\CMS\\Factory;
|
||||||
|
use Joomla\\CMS\\HTML\\HTMLHelper;
|
||||||
|
use Joomla\\CMS\\Uri\\Uri;
|
||||||
|
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$wa = $app->getDocument()->getWebAssetManager();
|
||||||
|
|
||||||
|
// Load template assets
|
||||||
|
$wa->useStyle('template.{safe_name}')->useScript('template.{safe_name}');
|
||||||
|
?>
|
||||||
|
<!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>
|
||||||
|
<header>
|
||||||
|
<jdoc:include type="modules" name="header" style="html5" />
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<jdoc:include type="component" />
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<jdoc:include type="modules" name="footer" style="html5" />
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"templateDetails.xml": manifest,
|
||||||
|
"index.php": index_php,
|
||||||
|
"css/template.css": "/* Template styles */\n",
|
||||||
|
"js/template.js": "// Template JavaScript\n",
|
||||||
|
"images/.gitkeep": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_package_structure(name: str, description: str, author: str) -> Dict[str, str]:
|
||||||
|
"""Get directory structure and files for a package."""
|
||||||
|
safe_name = name.lower().replace(" ", "_")
|
||||||
|
pkg_name = f"pkg_{safe_name}"
|
||||||
|
|
||||||
|
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<extension type="package" version="4.0" method="upgrade">
|
||||||
|
<name>{name}</name>
|
||||||
|
<packagename>{safe_name}</packagename>
|
||||||
|
<author>{author}</author>
|
||||||
|
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||||
|
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||||
|
<license>GPL-3.0-or-later</license>
|
||||||
|
<authorEmail>hello@example.com</authorEmail>
|
||||||
|
<authorUrl>https://example.com</authorUrl>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<description>{description}</description>
|
||||||
|
|
||||||
|
<files folder="packages">
|
||||||
|
<!-- Add extension packages here -->
|
||||||
|
</files>
|
||||||
|
</extension>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
f"{pkg_name}.xml": manifest,
|
||||||
|
"packages/.gitkeep": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Scaffolding Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def create_extension(
|
||||||
|
ext_type: str,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
author: str,
|
||||||
|
output_dir: str = "src",
|
||||||
|
**kwargs
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Create extension scaffolding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ext_type: Extension type (component, module, plugin, template, package)
|
||||||
|
name: Extension name
|
||||||
|
description: Extension description
|
||||||
|
author: Author name
|
||||||
|
output_dir: Output directory
|
||||||
|
**kwargs: Additional type-specific options
|
||||||
|
"""
|
||||||
|
output_path = Path(output_dir)
|
||||||
|
|
||||||
|
# Get structure based on type
|
||||||
|
if ext_type == "component":
|
||||||
|
structure = get_component_structure(name, description, author)
|
||||||
|
elif ext_type == "module":
|
||||||
|
client = kwargs.get("client", "site")
|
||||||
|
structure = get_module_structure(name, description, author, client)
|
||||||
|
elif ext_type == "plugin":
|
||||||
|
group = kwargs.get("group", "system")
|
||||||
|
structure = get_plugin_structure(name, description, author, group)
|
||||||
|
elif ext_type == "template":
|
||||||
|
structure = get_template_structure(name, description, author)
|
||||||
|
elif ext_type == "package":
|
||||||
|
structure = get_package_structure(name, description, author)
|
||||||
|
else:
|
||||||
|
common.die(f"Unknown extension type: {ext_type}")
|
||||||
|
|
||||||
|
# Create files
|
||||||
|
common.log_section(f"Creating {ext_type}: {name}")
|
||||||
|
|
||||||
|
for file_path, content in structure.items():
|
||||||
|
full_path = output_path / file_path
|
||||||
|
|
||||||
|
# Create parent directories
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write file
|
||||||
|
full_path.write_text(content, encoding="utf-8")
|
||||||
|
common.log_success(f"Created: {file_path}")
|
||||||
|
|
||||||
|
common.log_section("Scaffolding Complete")
|
||||||
|
common.log_info(f"Extension files created in: {output_path}")
|
||||||
|
common.log_info(f"Extension type: {ext_type}")
|
||||||
|
common.log_info(f"Extension name: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Command Line Interface
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Create Joomla extension scaffolding",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# Create a component
|
||||||
|
%(prog)s component MyComponent "My Component Description" "John Doe"
|
||||||
|
|
||||||
|
# Create a module
|
||||||
|
%(prog)s module MyModule "My Module Description" "John Doe" --client site
|
||||||
|
|
||||||
|
# Create a plugin
|
||||||
|
%(prog)s plugin MyPlugin "My Plugin Description" "John Doe" --group system
|
||||||
|
|
||||||
|
# Create a template
|
||||||
|
%(prog)s template mytheme "My Theme Description" "John Doe"
|
||||||
|
|
||||||
|
# Create a package
|
||||||
|
%(prog)s package mypackage "My Package Description" "John Doe"
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"type",
|
||||||
|
choices=["component", "module", "plugin", "template", "package"],
|
||||||
|
help="Extension type to create"
|
||||||
|
)
|
||||||
|
parser.add_argument("name", help="Extension name")
|
||||||
|
parser.add_argument("description", help="Extension description")
|
||||||
|
parser.add_argument("author", help="Author name")
|
||||||
|
parser.add_argument(
|
||||||
|
"-o", "--output",
|
||||||
|
default="src",
|
||||||
|
help="Output directory (default: src)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--client",
|
||||||
|
choices=["site", "administrator"],
|
||||||
|
default="site",
|
||||||
|
help="Module client (site or administrator)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--group",
|
||||||
|
default="system",
|
||||||
|
help="Plugin group (system, content, user, etc.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
create_extension(
|
||||||
|
ext_type=args.type,
|
||||||
|
name=args.name,
|
||||||
|
description=args.description,
|
||||||
|
author=args.author,
|
||||||
|
output_dir=args.output,
|
||||||
|
client=args.client,
|
||||||
|
group=args.group
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
common.log_error(f"Failed to create extension: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
The verbose error output in the 'die' function is controlled by 'VERBOSE_ERRORS' environment variable which defaults to 'true'. This means stack traces and environment information will be printed by default on every error. In production or CI environments, this could leak sensitive information. Consider defaulting to 'false' or using a different default in CI environments (checking the CI environment variable).