Unused import A module is imported (using the import statement) but t… #54

Merged
jmiller-moko merged 3 commits from dev/security-update-2025-01-17 into main 2026-01-17 23:32:49 +00:00
5 changed files with 1063 additions and 1063 deletions

View File

@@ -32,210 +32,208 @@ USAGE: ./scripts/release/package_extension.py [output_dir] [version]
""" """
import argparse import argparse
import os
import shutil
import sys import sys
import zipfile import zipfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Set from typing import Set
# Add lib directory to path # Add lib directory to path
sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
try: try:
import common import common
import extension_utils import extension_utils
except ImportError: except ImportError:
print("ERROR: Cannot import required libraries", file=sys.stderr) print("ERROR: Cannot import required libraries", file=sys.stderr)
sys.exit(1) sys.exit(1)
# Exclusion patterns for packaging # Exclusion patterns for packaging
EXCLUDE_PATTERNS = { EXCLUDE_PATTERNS = {
# Version control # Version control
".git", ".gitignore", ".gitattributes", ".git", ".gitignore", ".gitattributes",
# IDE # IDE
".vscode", ".idea", "*.sublime-*", ".vscode", ".idea", "*.sublime-*",
# Development # Development
"node_modules", "vendor", ".env", ".env.*", "node_modules", "vendor", ".env", ".env.*",
# Documentation (optional, can be included) # Documentation (optional, can be included)
# Build artifacts # Build artifacts
"dist", "build", ".phpunit.cache", "dist", "build", ".phpunit.cache",
# Development tool caches and artifacts # Development tool caches and artifacts
".phpstan.cache", ".psalm", ".rector", ".phpstan.cache", ".psalm", ".rector",
"phpmd-cache", ".php-cs-fixer.cache", ".phplint-cache", "phpmd-cache", ".php-cs-fixer.cache", ".phplint-cache",
# OS files # OS files
".DS_Store", "Thumbs.db", ".DS_Store", "Thumbs.db",
# Logs # Logs
"*.log", "*.log",
# Tests # Tests
"tests", "test", "Tests", "tests", "test", "Tests",
# CI/CD # CI/CD
".github", ".github",
# Scripts # Scripts
"scripts", "scripts",
# Docs (can be included if needed) # Docs (can be included if needed)
"docs", "docs",
# Config files # Config files
"composer.json", "composer.lock", "composer.json", "composer.lock",
"package.json", "package-lock.json", "package.json", "package-lock.json",
"phpunit.xml", "phpstan.neon", "phpcs.xml", "phpunit.xml", "phpstan.neon", "phpcs.xml",
"codeception.yml", "psalm.xml", ".php-cs-fixer.php", "codeception.yml", "psalm.xml", ".php-cs-fixer.php",
# Others # Others
"README.md", "CHANGELOG.md", "CONTRIBUTING.md", "README.md", "CHANGELOG.md", "CONTRIBUTING.md",
"CODE_OF_CONDUCT.md", "SECURITY.md", "GOVERNANCE.md", "CODE_OF_CONDUCT.md", "SECURITY.md", "GOVERNANCE.md",
"Makefile", "Makefile",
} }
def should_exclude(path: Path, base_path: Path, exclude_patterns: Set[str]) -> bool: def should_exclude(path: Path, base_path: Path, exclude_patterns: Set[str]) -> bool:
""" """
Check if a path should be excluded from packaging. Check if a path should be excluded from packaging.
Args: Args:
path: Path to check path: Path to check
base_path: Base directory path base_path: Base directory path
exclude_patterns: Set of exclusion patterns exclude_patterns: Set of exclusion patterns
Returns: Returns:
True if should be excluded True if should be excluded
""" """
relative_path = path.relative_to(base_path) relative_path = path.relative_to(base_path)
# Check each part of the path # Check each part of the path
for part in relative_path.parts: for part in relative_path.parts:
if part in exclude_patterns: if part in exclude_patterns:
return True return True
# Check wildcard patterns # Check wildcard patterns
for pattern in exclude_patterns: for pattern in exclude_patterns:
if "*" in pattern: if "*" in pattern:
import fnmatch import fnmatch
if fnmatch.fnmatch(part, pattern): if fnmatch.fnmatch(part, pattern):
return True return True
return False return False
def create_package( def create_package(
src_dir: str, src_dir: str,
output_dir: str, output_dir: str,
version: str = None, version: str = None,
repo_name: str = None, repo_name: str = None,
exclude_patterns: Set[str] = None exclude_patterns: Set[str] = None
) -> Path: ) -> Path:
""" """
Create a distributable ZIP package for a Joomla or Dolibarr extension. Create a distributable ZIP package for a Joomla or Dolibarr extension.
Args: Args:
src_dir: Source directory containing extension files src_dir: Source directory containing extension files
output_dir: Output directory for ZIP file output_dir: Output directory for ZIP file
version: Version string (auto-detected if not provided) version: Version string (auto-detected if not provided)
repo_name: Repository name for ZIP file naming repo_name: Repository name for ZIP file naming
exclude_patterns: Patterns to exclude from packaging exclude_patterns: Patterns to exclude from packaging
Returns: Returns:
Path to created ZIP file Path to created ZIP file
""" """
src_path = Path(src_dir) src_path = Path(src_dir)
if not src_path.is_dir(): if not src_path.is_dir():
common.die(f"Source directory not found: {src_dir}") common.die(f"Source directory not found: {src_dir}")
# Detect extension platform and get info # Detect extension platform and get info
ext_info = extension_utils.get_extension_info(src_dir) ext_info = extension_utils.get_extension_info(src_dir)
if not ext_info: if not ext_info:
common.die(f"No Joomla or Dolibarr extension found in {src_dir}") common.die(f"No Joomla or Dolibarr extension found in {src_dir}")
# Determine version # Determine version
if not version: if not version:
version = ext_info.version version = ext_info.version
# Determine repo name # Determine repo name
if not repo_name: if not repo_name:
try: try:
repo_root = common.git_root() repo_root = common.git_root()
repo_name = repo_root.name repo_name = repo_root.name
except Exception: except Exception:
repo_name = "extension" repo_name = "extension"
# Determine exclusion patterns # Determine exclusion patterns
if exclude_patterns is None: if exclude_patterns is None:
exclude_patterns = EXCLUDE_PATTERNS exclude_patterns = EXCLUDE_PATTERNS
# Create output directory # Create output directory
output_path = Path(output_dir) output_path = Path(output_dir)
common.ensure_dir(output_path) common.ensure_dir(output_path)
# Generate ZIP filename # Generate ZIP filename
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
platform_suffix = f"{ext_info.platform.value}-{ext_info.extension_type}" platform_suffix = f"{ext_info.platform.value}-{ext_info.extension_type}"
zip_filename = f"{repo_name}-{version}-{platform_suffix}.zip" zip_filename = f"{repo_name}-{version}-{platform_suffix}-{timestamp}.zip"
zip_path = output_path / zip_filename zip_path = output_path / zip_filename
# Remove existing ZIP if present # Remove existing ZIP if present
if zip_path.exists(): if zip_path.exists():
zip_path.unlink() zip_path.unlink()
common.log_section("Creating Extension Package") common.log_section("Creating Extension Package")
common.log_kv("Platform", ext_info.platform.value.upper()) common.log_kv("Platform", ext_info.platform.value.upper())
common.log_kv("Extension", ext_info.name) common.log_kv("Extension", ext_info.name)
common.log_kv("Type", ext_info.extension_type) common.log_kv("Type", ext_info.extension_type)
common.log_kv("Version", version) common.log_kv("Version", version)
common.log_kv("Source", src_dir) common.log_kv("Source", src_dir)
common.log_kv("Output", str(zip_path)) common.log_kv("Output", str(zip_path))
print() print()
# Create ZIP file # Create ZIP file
file_count = 0 file_count = 0
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for item in src_path.rglob("*"): for item in src_path.rglob("*"):
if item.is_file(): if item.is_file():
# Check if should be excluded # Check if should be excluded
if should_exclude(item, src_path, exclude_patterns): if should_exclude(item, src_path, exclude_patterns):
continue continue
# Add to ZIP with relative path # Add to ZIP with relative path
arcname = item.relative_to(src_path) arcname = item.relative_to(src_path)
zipf.write(item, arcname) zipf.write(item, arcname)
file_count += 1 file_count += 1
if file_count % 10 == 0: if file_count % 10 == 0:
common.log_step(f"Added {file_count} files...") common.log_step(f"Added {file_count} files...")
# Get ZIP file size # Get ZIP file size
zip_size = zip_path.stat().st_size zip_size = zip_path.stat().st_size
zip_size_mb = zip_size / (1024 * 1024) zip_size_mb = zip_size / (1024 * 1024)
print() print()
common.log_success(f"Package created: {zip_path.name}") common.log_success(f"Package created: {zip_path.name}")
common.log_kv("Files", str(file_count)) common.log_kv("Files", str(file_count))
common.log_kv("Size", f"{zip_size_mb:.2f} MB") common.log_kv("Size", f"{zip_size_mb:.2f} MB")
# Output JSON for machine consumption # Output JSON for machine consumption
result = { result = {
"status": "ok", "status": "ok",
"platform": ext_info.platform.value, "platform": ext_info.platform.value,
"extension": ext_info.name, "extension": ext_info.name,
"ext_type": ext_info.extension_type, "ext_type": ext_info.extension_type,
"version": version, "version": version,
"package": str(zip_path), "package": str(zip_path),
"files": file_count, "files": file_count,
"size_bytes": zip_size "size_bytes": zip_size
} }
print() print()
common.json_output(result) common.json_output(result)
return zip_path return zip_path
def main() -> int: def main() -> int:
"""Main entry point.""" """Main entry point."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Package Joomla or Dolibarr extension as distributable ZIP", description="Package Joomla or Dolibarr extension as distributable ZIP",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog="""
Examples: Examples:
# Package with auto-detected version # Package with auto-detected version
%(prog)s %(prog)s
@@ -251,73 +249,79 @@ Examples:
Supports both Joomla and Dolibarr extensions with automatic platform detection. Supports both Joomla and Dolibarr extensions with automatic platform detection.
""" """
) )
parser.add_argument( parser.add_argument(
"output_dir", "output_dir",
nargs="?", nargs="?",
default="dist", default="dist",
help="Output directory for ZIP file (default: dist)" help="Output directory for ZIP file (default: dist)"
) )
parser.add_argument( parser.add_argument(
"version", "version",
nargs="?", nargs="?",
help="Version string (default: auto-detected from manifest)" help="Version string (default: auto-detected from manifest)"
) )
parser.add_argument( parser.add_argument(
"-s", "--src-dir", "-s", "--src-dir",
default="src", default="src",
help="Source directory (default: src)" help="Source directory (default: src)"
) )
parser.add_argument( parser.add_argument(
"--repo-name", "--repo-name",
help="Repository name for ZIP filename (default: auto-detected)" help="Repository name for ZIP filename (default: auto-detected)"
) )
parser.add_argument( parser.add_argument(
"--include-docs", "--include-docs",
action="store_true", action="store_true",
help="Include documentation files in package" help="Include documentation files in package"
) )
parser.add_argument( parser.add_argument(
"--include-tests", "--include-tests",
action="store_true", action="store_true",
help="Include test files in package" help="Include test files in package"
) )
args = parser.parse_args() args = parser.parse_args()
try: try:
# Adjust exclusion patterns based on arguments # Adjust exclusion patterns based on arguments
exclude_patterns = EXCLUDE_PATTERNS.copy() exclude_patterns = EXCLUDE_PATTERNS.copy()
if args.include_docs: if args.include_docs:
exclude_patterns.discard("docs") exclude_patterns.discard("docs")
exclude_patterns.discard("README.md") exclude_patterns.discard("README.md")
exclude_patterns.discard("CHANGELOG.md") exclude_patterns.discard("CHANGELOG.md")
if args.include_tests: if args.include_tests:
exclude_patterns.discard("tests") exclude_patterns.discard("tests")
exclude_patterns.discard("test") exclude_patterns.discard("test")
exclude_patterns.discard("Tests") exclude_patterns.discard("Tests")
# Create package # Create package
zip_path = create_package( zip_path = create_package(
src_dir=args.src_dir, src_dir=args.src_dir,
output_dir=args.output_dir, output_dir=args.output_dir,
version=args.version, version=args.version,
repo_name=args.repo_name, repo_name=args.repo_name,
exclude_patterns=exclude_patterns exclude_patterns=exclude_patterns
) )
return 0 result = {
"status": "success",
except Exception as e: "zip_path": str(zip_path)
common.log_error(f"Packaging failed: {e}") }
result = { common.json_output(result)
"status": "error",
"error": str(e) return 0
}
common.json_output(result) except Exception as e:
return 1 common.log_error(f"Packaging failed: {e}")
result = {
"status": "error",
"error": str(e)
}
common.json_output(result)
return 1
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

@@ -40,11 +40,10 @@ from typing import Dict
sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
try: try:
import common import common
import joomla_manifest
except ImportError: except ImportError:
print("ERROR: Cannot import required libraries", file=sys.stderr) print("ERROR: Cannot import required libraries", file=sys.stderr)
sys.exit(1) sys.exit(1)
# ============================================================================ # ============================================================================
@@ -52,73 +51,73 @@ except ImportError:
# ============================================================================ # ============================================================================
def get_component_structure(name: str, description: str, author: str) -> Dict[str, str]: def get_component_structure(name: str, description: str, author: str) -> Dict[str, str]:
"""Get directory structure and files for a component.""" """Get directory structure and files for a component."""
safe_name = name.lower().replace(" ", "_") safe_name = name.lower().replace(" ", "_")
com_name = f"com_{safe_name}" com_name = f"com_{safe_name}"
manifest = f"""<?xml version="1.0" encoding="utf-8"?> manifest = f"""<?xml version="1.0" encoding="utf-8"?>
<extension type="component" version="4.0" method="upgrade"> <extension type="component" version="4.0" method="upgrade">
<name>{name}</name> <name>{name}</name>
<author>{author}</author> <author>{author}</author>
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate> <creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
<copyright>Copyright (C) {datetime.now().year} {author}</copyright> <copyright>Copyright (C) {datetime.now().year} {author}</copyright>
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@example.com</authorEmail> <authorEmail>hello@example.com</authorEmail>
<authorUrl>https://example.com</authorUrl> <authorUrl>https://example.com</authorUrl>
<version>1.0.0</version> <version>1.0.0</version>
<description>{description}</description> <description>{description}</description>
<files folder="site"> <files folder="site">
<folder>src</folder> <folder>src</folder>
</files> </files>
<administration> <administration>
<menu>{name}</menu> <menu>{name}</menu>
<files folder="admin"> <files folder="admin">
<folder>services</folder> <folder>services</folder>
<folder>sql</folder> <folder>sql</folder>
<folder>src</folder> <folder>src</folder>
</files> </files>
</administration> </administration>
</extension> </extension>
""" """
return { return {
f"{com_name}.xml": manifest, f"{com_name}.xml": manifest,
"site/src/.gitkeep": "", "site/src/.gitkeep": "",
"admin/services/provider.php": f"<?php\n// Service provider for {name}\n", "admin/services/provider.php": f"<?php\n// Service provider for {name}\n",
"admin/sql/install.mysql.utf8.sql": "-- Installation SQL\n", "admin/sql/install.mysql.utf8.sql": "-- Installation SQL\n",
"admin/sql/uninstall.mysql.utf8.sql": "-- Uninstallation SQL\n", "admin/sql/uninstall.mysql.utf8.sql": "-- Uninstallation SQL\n",
"admin/src/.gitkeep": "", "admin/src/.gitkeep": "",
} }
def get_module_structure(name: str, description: str, author: str, client: str = "site") -> Dict[str, str]: def get_module_structure(name: str, description: str, author: str, client: str = "site") -> Dict[str, str]:
"""Get directory structure and files for a module.""" """Get directory structure and files for a module."""
safe_name = name.lower().replace(" ", "_") safe_name = name.lower().replace(" ", "_")
mod_name = f"mod_{safe_name}" mod_name = f"mod_{safe_name}"
manifest = f"""<?xml version="1.0" encoding="utf-8"?> manifest = f"""<?xml version="1.0" encoding="utf-8"?>
<extension type="module" version="4.0" client="{client}" method="upgrade"> <extension type="module" version="4.0" client="{client}" method="upgrade">
<name>{name}</name> <name>{name}</name>
<author>{author}</author> <author>{author}</author>
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate> <creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
<copyright>Copyright (C) {datetime.now().year} {author}</copyright> <copyright>Copyright (C) {datetime.now().year} {author}</copyright>
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@example.com</authorEmail> <authorEmail>hello@example.com</authorEmail>
<authorUrl>https://example.com</authorUrl> <authorUrl>https://example.com</authorUrl>
<version>1.0.0</version> <version>1.0.0</version>
<description>{description}</description> <description>{description}</description>
<files> <files>
<filename module="{mod_name}">{mod_name}.php</filename> <filename module="{mod_name}">{mod_name}.php</filename>
<filename>{mod_name}.xml</filename> <filename>{mod_name}.xml</filename>
<folder>tmpl</folder> <folder>tmpl</folder>
</files> </files>
</extension> </extension>
""" """
module_php = f"""<?php module_php = f"""<?php
/** /**
* @package {name} * @package {name}
* @copyright Copyright (C) {datetime.now().year} {author} * @copyright Copyright (C) {datetime.now().year} {author}
@@ -130,8 +129,8 @@ defined('_JEXEC') or die;
// Module logic here // Module logic here
require JModuleHelper::getLayoutPath('mod_{safe_name}', $params->get('layout', 'default')); require JModuleHelper::getLayoutPath('mod_{safe_name}', $params->get('layout', 'default'));
""" """
default_tmpl = f"""<?php default_tmpl = f"""<?php
/** /**
* @package {name} * @package {name}
* @copyright Copyright (C) {datetime.now().year} {author} * @copyright Copyright (C) {datetime.now().year} {author}
@@ -141,41 +140,41 @@ require JModuleHelper::getLayoutPath('mod_{safe_name}', $params->get('layout', '
defined('_JEXEC') or die; defined('_JEXEC') or die;
?> ?>
<div class="{mod_name}"> <div class="{mod_name}">
<p><?php echo JText::_('MOD_{safe_name.upper()}_DESCRIPTION'); ?></p> <p><?php echo JText::_('MOD_{safe_name.upper()}_DESCRIPTION'); ?></p>
</div> </div>
""" """
return { return {
f"{mod_name}.xml": manifest, f"{mod_name}.xml": manifest,
f"{mod_name}.php": module_php, f"{mod_name}.php": module_php,
"tmpl/default.php": default_tmpl, "tmpl/default.php": default_tmpl,
} }
def get_plugin_structure(name: str, description: str, author: str, group: str = "system") -> Dict[str, str]: def get_plugin_structure(name: str, description: str, author: str, group: str = "system") -> Dict[str, str]:
"""Get directory structure and files for a plugin.""" """Get directory structure and files for a plugin."""
safe_name = name.lower().replace(" ", "_") safe_name = name.lower().replace(" ", "_")
plg_name = f"{safe_name}" plg_name = f"{safe_name}"
manifest = f"""<?xml version="1.0" encoding="utf-8"?> manifest = f"""<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" version="4.0" group="{group}" method="upgrade"> <extension type="plugin" version="4.0" group="{group}" method="upgrade">
<name>plg_{group}_{safe_name}</name> <name>plg_{group}_{safe_name}</name>
<author>{author}</author> <author>{author}</author>
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate> <creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
<copyright>Copyright (C) {datetime.now().year} {author}</copyright> <copyright>Copyright (C) {datetime.now().year} {author}</copyright>
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@example.com</authorEmail> <authorEmail>hello@example.com</authorEmail>
<authorUrl>https://example.com</authorUrl> <authorUrl>https://example.com</authorUrl>
<version>1.0.0</version> <version>1.0.0</version>
<description>{description}</description> <description>{description}</description>
<files> <files>
<filename plugin="{plg_name}">{plg_name}.php</filename> <filename plugin="{plg_name}">{plg_name}.php</filename>
</files> </files>
</extension> </extension>
""" """
plugin_php = f"""<?php plugin_php = f"""<?php
/** /**
* @package {name} * @package {name}
* @copyright Copyright (C) {datetime.now().year} {author} * @copyright Copyright (C) {datetime.now().year} {author}
@@ -188,54 +187,54 @@ use Joomla\\CMS\\Plugin\\CMSPlugin;
class Plg{group.capitalize()}{plg_name.capitalize()} extends CMSPlugin class Plg{group.capitalize()}{plg_name.capitalize()} extends CMSPlugin
{{ {{
protected $autoloadLanguage = true; protected $autoloadLanguage = true;
public function onContentPrepare($context, &$article, &$params, $limitstart = 0) public function onContentPrepare($context, &$article, &$params, $limitstart = 0)
{{ {{
// Plugin logic here // Plugin logic here
}} }}
}} }}
""" """
return { return {
f"plg_{group}_{safe_name}.xml": manifest, f"plg_{group}_{safe_name}.xml": manifest,
f"{plg_name}.php": plugin_php, f"{plg_name}.php": plugin_php,
} }
def get_template_structure(name: str, description: str, author: str) -> Dict[str, str]: def get_template_structure(name: str, description: str, author: str) -> Dict[str, str]:
"""Get directory structure and files for a template.""" """Get directory structure and files for a template."""
safe_name = name.lower().replace(" ", "_") safe_name = name.lower().replace(" ", "_")
manifest = f"""<?xml version="1.0" encoding="utf-8"?> manifest = f"""<?xml version="1.0" encoding="utf-8"?>
<extension type="template" version="4.0" client="site" method="upgrade"> <extension type="template" version="4.0" client="site" method="upgrade">
<name>{safe_name}</name> <name>{safe_name}</name>
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate> <creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
<author>{author}</author> <author>{author}</author>
<authorEmail>hello@example.com</authorEmail> <authorEmail>hello@example.com</authorEmail>
<authorUrl>https://example.com</authorUrl> <authorUrl>https://example.com</authorUrl>
<copyright>Copyright (C) {datetime.now().year} {author}</copyright> <copyright>Copyright (C) {datetime.now().year} {author}</copyright>
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<version>1.0.0</version> <version>1.0.0</version>
<description>{description}</description> <description>{description}</description>
<files> <files>
<filename>index.php</filename> <filename>index.php</filename>
<filename>templateDetails.xml</filename> <filename>templateDetails.xml</filename>
<folder>css</folder> <folder>css</folder>
<folder>js</folder> <folder>js</folder>
<folder>images</folder> <folder>images</folder>
</files> </files>
<positions> <positions>
<position>header</position> <position>header</position>
<position>main</position> <position>main</position>
<position>footer</position> <position>footer</position>
</positions> </positions>
</extension> </extension>
""" """
index_php = f"""<?php index_php = f"""<?php
/** /**
* @package {name} * @package {name}
* @copyright Copyright (C) {datetime.now().year} {author} * @copyright Copyright (C) {datetime.now().year} {author}
@@ -257,61 +256,61 @@ $wa->useStyle('template.{safe_name}')->useScript('template.{safe_name}');
<!DOCTYPE html> <!DOCTYPE html>
<html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>"> <html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
<head> <head>
<jdoc:include type="metas" /> <jdoc:include type="metas" />
<jdoc:include type="styles" /> <jdoc:include type="styles" />
<jdoc:include type="scripts" /> <jdoc:include type="scripts" />
</head> </head>
<body> <body>
<header> <header>
<jdoc:include type="modules" name="header" style="html5" /> <jdoc:include type="modules" name="header" style="html5" />
</header> </header>
<main> <main>
<jdoc:include type="component" /> <jdoc:include type="component" />
</main> </main>
<footer> <footer>
<jdoc:include type="modules" name="footer" style="html5" /> <jdoc:include type="modules" name="footer" style="html5" />
</footer> </footer>
</body> </body>
</html> </html>
""" """
return { return {
"templateDetails.xml": manifest, "templateDetails.xml": manifest,
"index.php": index_php, "index.php": index_php,
"css/template.css": "/* Template styles */\n", "css/template.css": "/* Template styles */\n",
"js/template.js": "// Template JavaScript\n", "js/template.js": "// Template JavaScript\n",
"images/.gitkeep": "", "images/.gitkeep": "",
} }
def get_package_structure(name: str, description: str, author: str) -> Dict[str, str]: def get_package_structure(name: str, description: str, author: str) -> Dict[str, str]:
"""Get directory structure and files for a package.""" """Get directory structure and files for a package."""
safe_name = name.lower().replace(" ", "_") safe_name = name.lower().replace(" ", "_")
pkg_name = f"pkg_{safe_name}" pkg_name = f"pkg_{safe_name}"
manifest = f"""<?xml version="1.0" encoding="utf-8"?> manifest = f"""<?xml version="1.0" encoding="utf-8"?>
<extension type="package" version="4.0" method="upgrade"> <extension type="package" version="4.0" method="upgrade">
<name>{name}</name> <name>{name}</name>
<packagename>{safe_name}</packagename> <packagename>{safe_name}</packagename>
<author>{author}</author> <author>{author}</author>
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate> <creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
<copyright>Copyright (C) {datetime.now().year} {author}</copyright> <copyright>Copyright (C) {datetime.now().year} {author}</copyright>
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@example.com</authorEmail> <authorEmail>hello@example.com</authorEmail>
<authorUrl>https://example.com</authorUrl> <authorUrl>https://example.com</authorUrl>
<version>1.0.0</version> <version>1.0.0</version>
<description>{description}</description> <description>{description}</description>
<files folder="packages"> <files folder="packages">
<!-- Add extension packages here --> <!-- Add extension packages here -->
</files> </files>
</extension> </extension>
""" """
return { return {
f"{pkg_name}.xml": manifest, f"{pkg_name}.xml": manifest,
"packages/.gitkeep": "", "packages/.gitkeep": "",
} }
# ============================================================================ # ============================================================================
@@ -319,59 +318,59 @@ def get_package_structure(name: str, description: str, author: str) -> Dict[str,
# ============================================================================ # ============================================================================
def create_extension( def create_extension(
ext_type: str, ext_type: str,
name: str, name: str,
description: str, description: str,
author: str, author: str,
output_dir: str = "src", output_dir: str = "src",
**kwargs **kwargs
) -> None: ) -> None:
""" """
Create extension scaffolding. Create extension scaffolding.
Args: Args:
ext_type: Extension type (component, module, plugin, template, package) ext_type: Extension type (component, module, plugin, template, package)
name: Extension name name: Extension name
description: Extension description description: Extension description
author: Author name author: Author name
output_dir: Output directory output_dir: Output directory
**kwargs: Additional type-specific options **kwargs: Additional type-specific options
""" """
output_path = Path(output_dir) output_path = Path(output_dir)
# Get structure based on type # Get structure based on type
if ext_type == "component": if ext_type == "component":
structure = get_component_structure(name, description, author) structure = get_component_structure(name, description, author)
elif ext_type == "module": elif ext_type == "module":
client = kwargs.get("client", "site") client = kwargs.get("client", "site")
structure = get_module_structure(name, description, author, client) structure = get_module_structure(name, description, author, client)
elif ext_type == "plugin": elif ext_type == "plugin":
group = kwargs.get("group", "system") group = kwargs.get("group", "system")
structure = get_plugin_structure(name, description, author, group) structure = get_plugin_structure(name, description, author, group)
elif ext_type == "template": elif ext_type == "template":
structure = get_template_structure(name, description, author) structure = get_template_structure(name, description, author)
elif ext_type == "package": elif ext_type == "package":
structure = get_package_structure(name, description, author) structure = get_package_structure(name, description, author)
else: else:
common.die(f"Unknown extension type: {ext_type}") common.die(f"Unknown extension type: {ext_type}")
# Create files # Create files
common.log_section(f"Creating {ext_type}: {name}") common.log_section(f"Creating {ext_type}: {name}")
for file_path, content in structure.items(): for file_path, content in structure.items():
full_path = output_path / file_path full_path = output_path / file_path
# Create parent directories # Create parent directories
full_path.parent.mkdir(parents=True, exist_ok=True) full_path.parent.mkdir(parents=True, exist_ok=True)
# Write file # Write file
full_path.write_text(content, encoding="utf-8") full_path.write_text(content, encoding="utf-8")
common.log_success(f"Created: {file_path}") common.log_success(f"Created: {file_path}")
common.log_section("Scaffolding Complete") common.log_section("Scaffolding Complete")
common.log_info(f"Extension files created in: {output_path}") common.log_info(f"Extension files created in: {output_path}")
common.log_info(f"Extension type: {ext_type}") common.log_info(f"Extension type: {ext_type}")
common.log_info(f"Extension name: {name}") common.log_info(f"Extension name: {name}")
# ============================================================================ # ============================================================================
@@ -379,11 +378,11 @@ def create_extension(
# ============================================================================ # ============================================================================
def main() -> None: def main() -> None:
"""Main entry point.""" """Main entry point."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Create Joomla extension scaffolding", description="Create Joomla extension scaffolding",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog="""
Examples: Examples:
# Create a component # Create a component
%(prog)s component MyComponent "My Component Description" "John Doe" %(prog)s component MyComponent "My Component Description" "John Doe"
@@ -400,49 +399,49 @@ Examples:
# Create a package # Create a package
%(prog)s package mypackage "My Package Description" "John Doe" %(prog)s package mypackage "My Package Description" "John Doe"
""" """
) )
parser.add_argument( parser.add_argument(
"type", "type",
choices=["component", "module", "plugin", "template", "package"], choices=["component", "module", "plugin", "template", "package"],
help="Extension type to create" help="Extension type to create"
) )
parser.add_argument("name", help="Extension name") parser.add_argument("name", help="Extension name")
parser.add_argument("description", help="Extension description") parser.add_argument("description", help="Extension description")
parser.add_argument("author", help="Author name") parser.add_argument("author", help="Author name")
parser.add_argument( parser.add_argument(
"-o", "--output", "-o", "--output",
default="src", default="src",
help="Output directory (default: src)" help="Output directory (default: src)"
) )
parser.add_argument( parser.add_argument(
"--client", "--client",
choices=["site", "administrator"], choices=["site", "administrator"],
default="site", default="site",
help="Module client (site or administrator)" help="Module client (site or administrator)"
) )
parser.add_argument( parser.add_argument(
"--group", "--group",
default="system", default="system",
help="Plugin group (system, content, user, etc.)" help="Plugin group (system, content, user, etc.)"
) )
args = parser.parse_args() args = parser.parse_args()
try: try:
create_extension( create_extension(
ext_type=args.type, ext_type=args.type,
name=args.name, name=args.name,
description=args.description, description=args.description,
author=args.author, author=args.author,
output_dir=args.output, output_dir=args.output,
client=args.client, client=args.client,
group=args.group group=args.group
) )
except Exception as e: except Exception as e:
common.log_error(f"Failed to create extension: {e}") common.log_error(f"Failed to create extension: {e}")
sys.exit(1) sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -33,149 +33,149 @@ BRIEF: Run all validation scripts
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from typing import List, Tuple from typing import Tuple
# Add lib directory to path # Add lib directory to path
sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
try: try:
import common import common
except ImportError: except ImportError:
print("ERROR: Cannot import required libraries", file=sys.stderr) print("ERROR: Cannot import required libraries", file=sys.stderr)
sys.exit(1) sys.exit(1)
# Required validation scripts (must pass) # Required validation scripts (must pass)
REQUIRED_SCRIPTS = [ REQUIRED_SCRIPTS = [
"scripts/validate/manifest.py", "scripts/validate/manifest.py",
"scripts/validate/xml_wellformed.py", "scripts/validate/xml_wellformed.py",
"scripts/validate/workflows.py", "scripts/validate/workflows.py",
] ]
# Optional validation scripts (failures are warnings) # Optional validation scripts (failures are warnings)
OPTIONAL_SCRIPTS = [ OPTIONAL_SCRIPTS = [
"scripts/validate/changelog.py", "scripts/validate/changelog.py",
"scripts/validate/language_structure.py", "scripts/validate/language_structure.py",
"scripts/validate/license_headers.py", "scripts/validate/license_headers.py",
"scripts/validate/no_secrets.py", "scripts/validate/no_secrets.py",
"scripts/validate/paths.py", "scripts/validate/paths.py",
"scripts/validate/php_syntax.py", "scripts/validate/php_syntax.py",
"scripts/validate/tabs.py", "scripts/validate/tabs.py",
"scripts/validate/version_alignment.py", "scripts/validate/version_alignment.py",
"scripts/validate/version_hierarchy.py", "scripts/validate/version_hierarchy.py",
] ]
def run_validation_script(script_path: str) -> Tuple[bool, str]: def run_validation_script(script_path: str) -> Tuple[bool, str]:
""" """
Run a validation script. Run a validation script.
Args: Args:
script_path: Path to script script_path: Path to script
Returns: Returns:
Tuple of (success, output) Tuple of (success, output)
""" """
script = Path(script_path) script = Path(script_path)
if not script.exists(): if not script.exists():
return (False, f"Script not found: {script_path}") return (False, f"Script not found: {script_path}")
try: try:
result = subprocess.run( result = subprocess.run(
["python3", str(script)], ["python3", str(script)],
capture_output=True, capture_output=True,
text=True, text=True,
check=False check=False
) )
output = result.stdout + result.stderr output = result.stdout + result.stderr
success = result.returncode == 0 success = result.returncode == 0
return (success, output) return (success, output)
except Exception as e: except Exception as e:
return (False, f"Error running script: {e}") return (False, f"Error running script: {e}")
def main() -> int: def main() -> int:
"""Main entry point.""" """Main entry point."""
common.log_section("Running All Validations") common.log_section("Running All Validations")
print() print()
total_passed = 0 total_passed = 0
total_failed = 0 total_failed = 0
total_skipped = 0 total_skipped = 0
# Run required scripts # Run required scripts
common.log_info("=== Required Validations ===") common.log_info("=== Required Validations ===")
print() print()
for script in REQUIRED_SCRIPTS: for script in REQUIRED_SCRIPTS:
script_name = Path(script).name script_name = Path(script).name
common.log_info(f"Running {script_name}...") common.log_info(f"Running {script_name}...")
success, output = run_validation_script(script) success, output = run_validation_script(script)
if success: if success:
common.log_success(f"{script_name} passed") common.log_success(f"{script_name} passed")
total_passed += 1 total_passed += 1
else: else:
common.log_error(f"{script_name} FAILED") common.log_error(f"{script_name} FAILED")
if output: if output:
print(output) print(output)
total_failed += 1 total_failed += 1
print() print()
# Run optional scripts # Run optional scripts
common.log_info("=== Optional Validations ===") common.log_info("=== Optional Validations ===")
print() print()
for script in OPTIONAL_SCRIPTS: for script in OPTIONAL_SCRIPTS:
script_name = Path(script).name script_name = Path(script).name
if not Path(script).exists(): if not Path(script).exists():
common.log_warn(f"{script_name} not found (skipped)") common.log_warn(f"{script_name} not found (skipped)")
total_skipped += 1 total_skipped += 1
continue continue
common.log_info(f"Running {script_name}...") common.log_info(f"Running {script_name}...")
success, output = run_validation_script(script) success, output = run_validation_script(script)
if success: if success:
common.log_success(f"{script_name} passed") common.log_success(f"{script_name} passed")
total_passed += 1 total_passed += 1
else: else:
common.log_warn(f"{script_name} failed (optional)") common.log_warn(f"{script_name} failed (optional)")
if output: if output:
print(output[:500]) # Limit output print(output[:500]) # Limit output
total_failed += 1 total_failed += 1
print() print()
# Summary # Summary
common.log_section("Validation Summary") common.log_section("Validation Summary")
common.log_kv("Total Passed", str(total_passed)) common.log_kv("Total Passed", str(total_passed))
common.log_kv("Total Failed", str(total_failed)) common.log_kv("Total Failed", str(total_failed))
common.log_kv("Total Skipped", str(total_skipped)) common.log_kv("Total Skipped", str(total_skipped))
print() print()
# Check if any required validations failed # Check if any required validations failed
required_failed = sum( required_failed = sum(
1 for script in REQUIRED_SCRIPTS 1 for script in REQUIRED_SCRIPTS
if Path(script).exists() and not run_validation_script(script)[0] if Path(script).exists() and not run_validation_script(script)[0]
) )
if required_failed > 0: if required_failed > 0:
common.log_error(f"{required_failed} required validation(s) failed") common.log_error(f"{required_failed} required validation(s) failed")
return 1 return 1
common.log_success("All required validations passed!") common.log_success("All required validations passed!")
if total_failed > 0: if total_failed > 0:
common.log_warn(f"{total_failed} optional validation(s) failed") common.log_warn(f"{total_failed} optional validation(s) failed")
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

@@ -33,185 +33,184 @@ NOTE: Checks YAML syntax, structure, and best practices
import sys import sys
from pathlib import Path from pathlib import Path
from typing import List, Tuple
# Add lib directory to path # Add lib directory to path
sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
try: try:
import common import common
except ImportError: except ImportError:
print("ERROR: Cannot import required libraries", file=sys.stderr) print("ERROR: Cannot import required libraries", file=sys.stderr)
sys.exit(1) sys.exit(1)
def validate_yaml_syntax(filepath: Path) -> bool: def validate_yaml_syntax(filepath: Path) -> bool:
""" """
Validate YAML syntax of a workflow file. Validate YAML syntax of a workflow file.
Args: Args:
filepath: Path to workflow file filepath: Path to workflow file
Returns: Returns:
True if valid True if valid
""" """
try: try:
import yaml import yaml
except ImportError: except ImportError:
common.log_warn("PyYAML module not installed. Install with: pip3 install pyyaml") common.log_warn("PyYAML module not installed. Install with: pip3 install pyyaml")
return True # Skip validation if yaml not available return True # Skip validation if yaml not available
try: try:
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, 'r', encoding='utf-8') as f:
yaml.safe_load(f) yaml.safe_load(f)
print(f"✓ Valid YAML: {filepath.name}") print(f"✓ Valid YAML: {filepath.name}")
return True return True
except yaml.YAMLError as e: except yaml.YAMLError as e:
print(f"✗ YAML Error in {filepath.name}: {e}", file=sys.stderr) print(f"✗ YAML Error in {filepath.name}: {e}", file=sys.stderr)
return False return False
except Exception as e: except Exception as e:
print(f"✗ Error reading {filepath.name}: {e}", file=sys.stderr) print(f"✗ Error reading {filepath.name}: {e}", file=sys.stderr)
return False return False
def check_no_tabs(filepath: Path) -> bool: def check_no_tabs(filepath: Path) -> bool:
""" """
Check that file contains no tab characters. Check that file contains no tab characters.
Args: Args:
filepath: Path to file filepath: Path to file
Returns: Returns:
True if no tabs found True if no tabs found
""" """
try: try:
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, 'r', encoding='utf-8') as f:
content = f.read() content = f.read()
if '\t' in content: if '\t' in content:
common.log_error(f"✗ File contains tab characters: {filepath.name}") common.log_error(f"✗ File contains tab characters: {filepath.name}")
return False return False
except Exception as e: except Exception as e:
common.log_warn(f"Could not read {filepath}: {e}") common.log_warn(f"Could not read {filepath}: {e}")
return False return False
return True return True
def check_workflow_structure(filepath: Path) -> bool: def check_workflow_structure(filepath: Path) -> bool:
""" """
Check workflow file structure for required keys. Check workflow file structure for required keys.
Args: Args:
filepath: Path to workflow file filepath: Path to workflow file
Returns: Returns:
True if structure is valid True if structure is valid
""" """
errors = 0 errors = 0
try: try:
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, 'r', encoding='utf-8') as f:
content = f.read() content = f.read()
# Check for required top-level keys # Check for required top-level keys
if 'name:' not in content and not content.startswith('name:'): if 'name:' not in content and not content.startswith('name:'):
common.log_warn(f"Missing 'name:' in {filepath.name}") common.log_warn(f"Missing 'name:' in {filepath.name}")
if 'on:' not in content and not content.startswith('on:'): if 'on:' not in content and not content.startswith('on:'):
common.log_error(f"✗ Missing 'on:' trigger in {filepath.name}") common.log_error(f"✗ Missing 'on:' trigger in {filepath.name}")
errors += 1 errors += 1
if 'jobs:' not in content and not content.startswith('jobs:'): if 'jobs:' not in content and not content.startswith('jobs:'):
common.log_error(f"✗ Missing 'jobs:' in {filepath.name}") common.log_error(f"✗ Missing 'jobs:' in {filepath.name}")
errors += 1 errors += 1
except Exception as e: except Exception as e:
common.log_error(f"Error reading {filepath}: {e}") common.log_error(f"Error reading {filepath}: {e}")
return False return False
return errors == 0 return errors == 0
def validate_workflow_file(filepath: Path) -> bool: def validate_workflow_file(filepath: Path) -> bool:
""" """
Validate a single workflow file. Validate a single workflow file.
Args: Args:
filepath: Path to workflow file filepath: Path to workflow file
Returns: Returns:
True if valid True if valid
""" """
common.log_info(f"Validating: {filepath.name}") common.log_info(f"Validating: {filepath.name}")
errors = 0 errors = 0
# Check YAML syntax # Check YAML syntax
if not validate_yaml_syntax(filepath): if not validate_yaml_syntax(filepath):
errors += 1 errors += 1
# Check for tabs # Check for tabs
if not check_no_tabs(filepath): if not check_no_tabs(filepath):
errors += 1 errors += 1
# Check structure # Check structure
if not check_workflow_structure(filepath): if not check_workflow_structure(filepath):
errors += 1 errors += 1
if errors == 0: if errors == 0:
common.log_info(f"{filepath.name} passed all checks") common.log_info(f"{filepath.name} passed all checks")
return True return True
else: else:
common.log_error(f"{filepath.name} failed {errors} check(s)") common.log_error(f"{filepath.name} failed {errors} check(s)")
return False return False
def main() -> int: def main() -> int:
"""Main entry point.""" """Main entry point."""
common.log_info("GitHub Actions Workflow Validation") common.log_info("GitHub Actions Workflow Validation")
common.log_info("===================================") common.log_info("===================================")
print() print()
workflows_dir = Path(".github/workflows") workflows_dir = Path(".github/workflows")
if not workflows_dir.is_dir(): if not workflows_dir.is_dir():
common.log_error(f"Workflows directory not found: {workflows_dir}") common.log_error(f"Workflows directory not found: {workflows_dir}")
return 1 return 1
# Find all workflow files # Find all workflow files
workflow_files = [] workflow_files = []
for pattern in ["*.yml", "*.yaml"]: for pattern in ["*.yml", "*.yaml"]:
workflow_files.extend(workflows_dir.glob(pattern)) workflow_files.extend(workflows_dir.glob(pattern))
if not workflow_files: if not workflow_files:
common.log_warn("No workflow files found") common.log_warn("No workflow files found")
return 0 return 0
total = len(workflow_files) total = len(workflow_files)
passed = 0 passed = 0
failed = 0 failed = 0
for workflow in workflow_files: for workflow in workflow_files:
if validate_workflow_file(workflow): if validate_workflow_file(workflow):
passed += 1 passed += 1
else: else:
failed += 1 failed += 1
print() print()
common.log_info("===================================") common.log_info("===================================")
common.log_info("Summary:") common.log_info("Summary:")
common.log_info(f" Total workflows: {total}") common.log_info(f" Total workflows: {total}")
common.log_info(f" Passed: {passed}") common.log_info(f" Passed: {passed}")
common.log_info(f" Failed: {failed}") common.log_info(f" Failed: {failed}")
common.log_info("===================================") common.log_info("===================================")
if failed > 0: if failed > 0:
common.log_error("Workflow validation failed") common.log_error("Workflow validation failed")
return 1 return 1
common.log_info("All workflows validated successfully") common.log_info("All workflows validated successfully")
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

@@ -19,322 +19,320 @@
BRIEF: Safe, configurable Google Tag Manager loader for Moko-Cassiopeia. BRIEF: Safe, configurable Google Tag Manager loader for Moko-Cassiopeia.
PATH: ./media/templates/site/moko-cassiopeia/js/gtm.js PATH: ./media/templates/site/moko-cassiopeia/js/gtm.js
NOTE: Place the <noscript> fallback iframe in your HTML template (index.php). A JS file NOTE: Place the <noscript> fallback iframe in your HTML template (index.php). A JS file
cannot provide a true no-JS fallback by definition. cannot provide a true no-JS fallback by definition.
VARIABLES: VARIABLES:
- window.MOKO_GTM_ID (string) // Optional global GTM container ID (e.g., "GTM-XXXXXXX") - window.MOKO_GTM_ID (string) // Optional global GTM container ID (e.g., "GTM-XXXXXXX")
- window.MOKO_GTM_OPTIONS (object) // Optional global options (see JSDoc below) - window.MOKO_GTM_OPTIONS (object) // Optional global options (see JSDoc below)
- data- attributes on the script tag or <html>/<body>: - data- attributes on the script tag or <html>/<body>:
data-gtm-id, data-data-layer, data-debug, data-ignore-dnt, data-gtm-id, data-data-layer, data-debug, data-ignore-dnt,
data-env-auth, data-env-preview, data-block-on-dev data-env-auth, data-env-preview, data-block-on-dev
*/ */
/* global window, document, navigator */ /* global window, document, navigator */
(() => { (() => {
"use strict"; "use strict";
/** /**
* @typedef {Object} MokoGtmOptions * @typedef {Object} MokoGtmOptions
* @property {string} [id] GTM container ID (e.g., "GTM-XXXXXXX") * @property {string} [id] GTM container ID (e.g., "GTM-XXXXXXX")
* @property {string} [dataLayerName] Custom dataLayer name (default: "dataLayer") * @property {string} [dataLayerName] Custom dataLayer name (default: "dataLayer")
* @property {boolean} [debug] Log debug messages to console (default: false) * @property {boolean} [debug] Log debug messages to console (default: false)
* @property {boolean} [ignoreDNT] Ignore Do Not Track and always load (default: false) * @property {boolean} [ignoreDNT] Ignore Do Not Track and always load (default: false)
* @property {boolean} [blockOnDev] Block loading on localhost/*.test/127.0.0.1 (default: true) * @property {boolean} [blockOnDev] Block loading on localhost/*.test/127.0.0.1 (default: true)
* @property {string} [envAuth] GTM Environment auth string (optional) * @property {string} [envAuth] GTM Environment auth string (optional)
* @property {string} [envPreview] GTM Environment preview name (optional) * @property {string} [envPreview] GTM Environment preview name (optional)
* @property {Record<string,'granted'|'denied'>} [consentDefault] * @property {Record<string,'granted'|'denied'>} [consentDefault]
* Default Consent Mode v2 map. Keys like: * Default Consent Mode v2 map. Keys like:
* analytics_storage, ad_storage, ad_user_data, ad_personalization, functionality_storage, security_storage * analytics_storage, ad_storage, ad_user_data, ad_personalization, functionality_storage, security_storage
* (default: {analytics_storage:'granted', functionality_storage:'granted', security_storage:'granted'}) * (default: {analytics_storage:'granted', functionality_storage:'granted', security_storage:'granted'})
* @property {() => (Record<string, any>|void)} [pageVars] * @property {() => (Record<string, any>|void)} [pageVars]
* Function returning extra page variables to push on init (optional) * Function returning extra page variables to push on init (optional)
*/ */
const PKG = "moko-gtm"; const PKG = "moko-gtm";
const PREFIX = `[${PKG}]`; const PREFIX = `[${PKG}]`;
const WIN = window; const WIN = window;
// Public API placeholder (attached to window at the end) // Public API placeholder (attached to window at the end)
/** @type {{ /** @type {{
* init: (opts?: Partial<MokoGtmOptions>) => void, * init: (opts?: Partial<MokoGtmOptions>) => void,
* setConsent: (updates: Record<string,'granted'|'denied'>) => void, * setConsent: (updates: Record<string,'granted'|'denied'>) => void,
* push: (...args:any[]) => void, * push: (...args:any[]) => void,
* isLoaded: () => boolean, * isLoaded: () => boolean,
* config: () => Required<MokoGtmOptions> * config: () => Required<MokoGtmOptions>
* }} */ * }} */
const API = {}; const API = {};
// ---- Utilities --------------------------------------------------------- // ---- Utilities ---------------------------------------------------------
const isDevHost = () => { const isDevHost = () => {
const h = WIN.location && WIN.location.hostname || ""; const h = WIN.location && WIN.location.hostname || "";
return ( return (
h === "localhost" || h === "localhost" ||
h === "127.0.0.1" || h === "127.0.0.1" ||
h.endsWith(".local") || h.endsWith(".local") ||
h.endsWith(".test") h.endsWith(".test")
); );
}; };
const dntEnabled = () => { const dntEnabled = () => {
// Different browsers expose DNT differently; treat "1" or "yes" as enabled. // Different browsers expose DNT differently; treat "1" or "yes" as enabled.
const n = navigator; const n = navigator;
const v = (n.doNotTrack || n.msDoNotTrack || (n.navigator && n.navigator.doNotTrack) || "").toString().toLowerCase(); const v = (n.doNotTrack || n.msDoNotTrack || (n.navigator && n.navigator.doNotTrack) || "").toString().toLowerCase();
return v === "1" || v === "yes"; return v === "1" || v === "yes";
}; };
const getCurrentScript = () => { const getCurrentScript = () => {
// document.currentScript is best; fallback to last <script> whose src ends with /gtm.js // document.currentScript is best; fallback to last <script> whose src ends with /gtm.js
const cs = document.currentScript; const cs = document.currentScript;
if (cs) return cs; if (cs) return cs;
const scripts = Array.from(document.getElementsByTagName("script")); const scripts = Array.from(document.getElementsByTagName("script"));
return scripts.reverse().find(s => (s.getAttribute("src") || "").includes("/gtm.js")) || null; return scripts.reverse().find(s => (s.getAttribute("src") || "").includes("/gtm.js")) || null;
}; };
const getAttr = (el, name) => el ? el.getAttribute(name) : null; const readDatasetCascade = (name) => {
// Check <script>, <html>, <body>, then <meta name="moko:gtm-<name>">
const script = getCurrentScript();
const html = document.documentElement;
const body = document.body;
const meta = document.querySelector(`meta[name="moko:gtm-${name}"]`);
return (
(script && script.dataset && script.dataset[name]) ||
(html && html.dataset && html.dataset[name]) ||
(body && body.dataset && body.dataset[name]) ||
(meta && meta.getAttribute("content")) ||
null
);
};
const readDatasetCascade = (name) => { const parseBool = (v, fallback = false) => {
// Check <script>, <html>, <body>, then <meta name="moko:gtm-<name>"> if (v == null) return fallback;
const script = getCurrentScript(); const s = String(v).trim().toLowerCase();
const html = document.documentElement; if (["1","true","yes","y","on"].includes(s)) return true;
const body = document.body; if (["0","false","no","n","off"].includes(s)) return false;
const meta = document.querySelector(`meta[name="moko:gtm-${name}"]`); return fallback;
return ( };
(script && script.dataset && script.dataset[name]) ||
(html && html.dataset && html.dataset[name]) ||
(body && body.dataset && body.dataset[name]) ||
(meta && meta.getAttribute("content")) ||
null
);
};
const parseBool = (v, fallback = false) => { const debugLog = (...args) => {
if (v == null) return fallback; if (STATE.debug) {
const s = String(v).trim().toLowerCase(); try { console.info(PREFIX, ...args); } catch (_) {}
if (["1","true","yes","y","on"].includes(s)) return true; }
if (["0","false","no","n","off"].includes(s)) return false; };
return fallback;
};
const debugLog = (...args) => { // ---- Configuration & State --------------------------------------------
if (STATE.debug) {
try { console.info(PREFIX, ...args); } catch (_) {}
}
};
// ---- Configuration & State -------------------------------------------- /** @type {Required<MokoGtmOptions>} */
const STATE = {
id: "",
dataLayerName: "dataLayer",
debug: false,
ignoreDNT: false,
blockOnDev: true,
envAuth: "",
envPreview: "",
consentDefault: {
analytics_storage: "granted",
functionality_storage: "granted",
security_storage: "granted",
// The following default to "denied" unless the site explicitly opts-in:
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
pageVars: () => ({})
};
/** @type {Required<MokoGtmOptions>} */ const mergeOptions = (base, extra = {}) => {
const STATE = { const out = {...base};
id: "", for (const k in extra) {
dataLayerName: "dataLayer", if (!Object.prototype.hasOwnProperty.call(extra, k)) continue;
debug: false, const v = extra[k];
ignoreDNT: false, if (v && typeof v === "object" && !Array.isArray(v)) {
blockOnDev: true, out[k] = {...(out[k] || {}), ...v};
envAuth: "", } else if (v !== undefined) {
envPreview: "", out[k] = v;
consentDefault: { }
analytics_storage: "granted", }
functionality_storage: "granted", return out;
security_storage: "granted", };
// The following default to "denied" unless the site explicitly opts-in:
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
pageVars: () => ({})
};
const mergeOptions = (base, extra = {}) => { const detectOptions = () => {
const out = {...base}; // 1) Global window options
for (const k in extra) { /** @type {Partial<MokoGtmOptions>} */
if (!Object.prototype.hasOwnProperty.call(extra, k)) continue; const globalOpts = (WIN.MOKO_GTM_OPTIONS && typeof WIN.MOKO_GTM_OPTIONS === "object") ? WIN.MOKO_GTM_OPTIONS : {};
const v = extra[k];
if (v && typeof v === "object" && !Array.isArray(v)) {
out[k] = {...(out[k] || {}), ...v};
} else if (v !== undefined) {
out[k] = v;
}
}
return out;
};
const detectOptions = () => { // 2) Dataset / meta
// 1) Global window options const idFromData = readDatasetCascade("id") || WIN.MOKO_GTM_ID || "";
/** @type {Partial<MokoGtmOptions>} */ const dlFromData = readDatasetCascade("dataLayer") || "";
const globalOpts = (WIN.MOKO_GTM_OPTIONS && typeof WIN.MOKO_GTM_OPTIONS === "object") ? WIN.MOKO_GTM_OPTIONS : {}; const dbgFromData = readDatasetCascade("debug");
const dntFromData = readDatasetCascade("ignoreDnt");
const devFromData = readDatasetCascade("blockOnDev");
const authFromData = readDatasetCascade("envAuth") || "";
const prevFromData = readDatasetCascade("envPreview") || "";
// 2) Dataset / meta // 3) Combine
const idFromData = readDatasetCascade("id") || WIN.MOKO_GTM_ID || ""; /** @type {Partial<MokoGtmOptions>} */
const dlFromData = readDatasetCascade("dataLayer") || ""; const detected = {
const dbgFromData = readDatasetCascade("debug"); id: idFromData || globalOpts.id || "",
const dntFromData = readDatasetCascade("ignoreDnt"); dataLayerName: dlFromData || globalOpts.dataLayerName || undefined,
const devFromData = readDatasetCascade("blockOnDev"); debug: parseBool(dbgFromData, !!globalOpts.debug),
const authFromData = readDatasetCascade("envAuth") || ""; ignoreDNT: parseBool(dntFromData, !!globalOpts.ignoreDNT),
const prevFromData = readDatasetCascade("envPreview") || ""; blockOnDev: parseBool(devFromData, (globalOpts.blockOnDev ?? true)),
envAuth: authFromData || globalOpts.envAuth || "",
envPreview: prevFromData || globalOpts.envPreview || "",
consentDefault: globalOpts.consentDefault || undefined,
pageVars: typeof globalOpts.pageVars === "function" ? globalOpts.pageVars : undefined
};
// 3) Combine return detected;
/** @type {Partial<MokoGtmOptions>} */ };
const detected = {
id: idFromData || globalOpts.id || "",
dataLayerName: dlFromData || globalOpts.dataLayerName || undefined,
debug: parseBool(dbgFromData, !!globalOpts.debug),
ignoreDNT: parseBool(dntFromData, !!globalOpts.ignoreDNT),
blockOnDev: parseBool(devFromData, (globalOpts.blockOnDev ?? true)),
envAuth: authFromData || globalOpts.envAuth || "",
envPreview: prevFromData || globalOpts.envPreview || "",
consentDefault: globalOpts.consentDefault || undefined,
pageVars: typeof globalOpts.pageVars === "function" ? globalOpts.pageVars : undefined
};
return detected; // ---- dataLayer / gtag helpers -----------------------------------------
};
// ---- dataLayer / gtag helpers ----------------------------------------- const ensureDataLayer = () => {
const l = STATE.dataLayerName;
WIN[l] = WIN[l] || [];
return WIN[l];
};
const ensureDataLayer = () => { /** gtag wrapper backed by dataLayer. */
const l = STATE.dataLayerName; const gtag = (...args) => {
WIN[l] = WIN[l] || []; const dl = ensureDataLayer();
return WIN[l]; dl.push(arguments.length > 1 ? args : args[0]);
}; debugLog("gtag push:", args);
};
/** gtag wrapper backed by dataLayer. */ API.push = (...args) => gtag(...args);
const gtag = (...args) => {
const dl = ensureDataLayer();
dl.push(arguments.length > 1 ? args : args[0]);
debugLog("gtag push:", args);
};
API.push = (...args) => gtag(...args); API.setConsent = (updates) => {
gtag("consent", "update", updates || {});
};
API.setConsent = (updates) => { API.isLoaded = () => {
gtag("consent", "update", updates || {}); const hasScript = !!document.querySelector('script[src*="googletagmanager.com/gtm.js"]');
}; return hasScript;
};
API.isLoaded = () => { API.config = () => ({...STATE});
const hasScript = !!document.querySelector('script[src*="googletagmanager.com/gtm.js"]');
return hasScript;
};
API.config = () => ({...STATE}); // ---- Loader ------------------------------------------------------------
// ---- Loader ------------------------------------------------------------ const buildEnvQuery = () => {
const qp = [];
if (STATE.envAuth) qp.push(`gtm_auth=${encodeURIComponent(STATE.envAuth)}`);
if (STATE.envPreview) qp.push(`gtm_preview=${encodeURIComponent(STATE.envPreview)}`, "gtm_cookies_win=x");
return qp.length ? `&${qp.join("&")}` : "";
};
const buildEnvQuery = () => { const injectScript = () => {
const qp = []; if (!STATE.id) {
if (STATE.envAuth) qp.push(`gtm_auth=${encodeURIComponent(STATE.envAuth)}`); debugLog("GTM ID missing; aborting load.");
if (STATE.envPreview) qp.push(`gtm_preview=${encodeURIComponent(STATE.envPreview)}`, "gtm_cookies_win=x"); return;
return qp.length ? `&${qp.join("&")}` : ""; }
}; if (API.isLoaded()) {
debugLog("GTM already loaded; skipping duplicate injection.");
return;
}
const injectScript = () => { // Standard GTM bootstrap timing event
if (!STATE.id) { const dl = ensureDataLayer();
debugLog("GTM ID missing; aborting load."); dl.push({ "gtm.start": new Date().getTime(), event: "gtm.js" });
return;
}
if (API.isLoaded()) {
debugLog("GTM already loaded; skipping duplicate injection.");
return;
}
// Standard GTM bootstrap timing event const f = document.getElementsByTagName("script")[0];
const dl = ensureDataLayer(); const j = document.createElement("script");
dl.push({ "gtm.start": new Date().getTime(), event: "gtm.js" }); j.async = true;
j.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(STATE.id)}${STATE.dataLayerName !== "dataLayer" ? `&l=${encodeURIComponent(STATE.dataLayerName)}` : ""}${buildEnvQuery()}`;
if (f && f.parentNode) {
f.parentNode.insertBefore(j, f);
} else {
(document.head || document.documentElement).appendChild(j);
}
debugLog("Injected GTM script:", j.src);
};
const f = document.getElementsByTagName("script")[0]; const applyDefaultConsent = () => {
const j = document.createElement("script"); // Consent Mode v2 default
j.async = true; gtag("consent", "default", STATE.consentDefault);
j.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(STATE.id)}${STATE.dataLayerName !== "dataLayer" ? `&l=${encodeURIComponent(STATE.dataLayerName)}` : ""}${buildEnvQuery()}`; debugLog("Applied default consent:", STATE.consentDefault);
if (f && f.parentNode) { };
f.parentNode.insertBefore(j, f);
} else {
(document.head || document.documentElement).appendChild(j);
}
debugLog("Injected GTM script:", j.src);
};
const applyDefaultConsent = () => { const pushInitialVars = () => {
// Consent Mode v2 default // Minimal page vars; allow site to add more via pageVars()
gtag("consent", "default", STATE.consentDefault); const vars = {
debugLog("Applied default consent:", STATE.consentDefault); event: "moko.page_init",
}; page_title: document.title || "",
page_language: (document.documentElement && document.documentElement.lang) || "",
...(typeof STATE.pageVars === "function" ? (STATE.pageVars() || {}) : {})
};
gtag(vars);
};
const pushInitialVars = () => { const shouldLoad = () => {
// Minimal page vars; allow site to add more via pageVars() if (!STATE.ignoreDNT && dntEnabled()) {
const vars = { debugLog("DNT is enabled; blocking GTM load (set ignoreDNT=true to override).");
event: "moko.page_init", return false;
page_title: document.title || "", }
page_language: (document.documentElement && document.documentElement.lang) || "", if (STATE.blockOnDev && isDevHost()) {
...(typeof STATE.pageVars === "function" ? (STATE.pageVars() || {}) : {}) debugLog("Development host detected; blocking GTM load (set blockOnDev=false to override).");
}; return false;
gtag(vars); }
}; return true;
};
const shouldLoad = () => { // ---- Public init -------------------------------------------------------
if (!STATE.ignoreDNT && dntEnabled()) {
debugLog("DNT is enabled; blocking GTM load (set ignoreDNT=true to override).");
return false;
}
if (STATE.blockOnDev && isDevHost()) {
debugLog("Development host detected; blocking GTM load (set blockOnDev=false to override).");
return false;
}
return true;
};
// ---- Public init ------------------------------------------------------- API.init = (opts = {}) => {
// Merge: defaults <- detected <- passed opts
const detected = detectOptions();
const merged = mergeOptions(STATE, mergeOptions(detected, opts));
API.init = (opts = {}) => { // Commit back to STATE
// Merge: defaults <- detected <- passed opts Object.assign(STATE, merged);
const detected = detectOptions();
const merged = mergeOptions(STATE, mergeOptions(detected, opts));
// Commit back to STATE debugLog("Config:", STATE);
Object.assign(STATE, merged);
debugLog("Config:", STATE); // Prepare dataLayer/gtag and consent
ensureDataLayer();
applyDefaultConsent();
pushInitialVars();
// Prepare dataLayer/gtag and consent // Load GTM if allowed
ensureDataLayer(); if (shouldLoad()) {
applyDefaultConsent(); injectScript();
pushInitialVars(); } else {
debugLog("GTM load prevented by configuration or environment.");
}
};
// Load GTM if allowed // ---- Auto-init on DOMContentLoaded (safe even if deferred) -------------
if (shouldLoad()) {
injectScript();
} else {
debugLog("GTM load prevented by configuration or environment.");
}
};
// ---- Auto-init on DOMContentLoaded (safe even if deferred) ------------- const autoInit = () => {
// Only auto-init if we have some ID from globals/datasets.
const detected = detectOptions();
const hasId = !!(detected.id || WIN.MOKO_GTM_ID);
if (hasId) {
API.init(); // use detected/global defaults
} else {
debugLog("No GTM ID detected; awaiting manual init via window.mokoGTM.init({ id: 'GTM-XXXXXXX' }).");
}
};
const autoInit = () => { if (document.readyState === "complete" || document.readyState === "interactive") {
// Only auto-init if we have some ID from globals/datasets. // Defer to ensure <body> exists for any late consumers.
const detected = detectOptions(); setTimeout(autoInit, 0);
const hasId = !!(detected.id || WIN.MOKO_GTM_ID); } else {
if (hasId) { document.addEventListener("DOMContentLoaded", autoInit, { once: true });
API.init(); // use detected/global defaults }
} else {
debugLog("No GTM ID detected; awaiting manual init via window.mokoGTM.init({ id: 'GTM-XXXXXXX' }).");
}
};
if (document.readyState === "complete" || document.readyState === "interactive") { // Expose API
// Defer to ensure <body> exists for any late consumers. WIN.mokoGTM = API;
setTimeout(autoInit, 0);
} else {
document.addEventListener("DOMContentLoaded", autoInit, { once: true });
}
// Expose API // Helpful console hint (only if debug true after detection)
WIN.mokoGTM = API; try {
const detected = detectOptions();
// Helpful console hint (only if debug true after detection) if (parseBool(detected.debug, false)) {
try { STATE.debug = true;
const detected = detectOptions(); debugLog("Ready. You can call window.mokoGTM.init({ id: 'GTM-XXXXXXX' }).");
if (parseBool(detected.debug, false)) { }
STATE.debug = true; } catch (_) {}
debugLog("Ready. You can call window.mokoGTM.init({ id: 'GTM-XXXXXXX' }).");
}
} catch (_) {}
})(); })();