Compare commits

..

No commits in common. "main" and "v1.0.5" have entirely different histories.
main ... v1.0.5

8 changed files with 8587 additions and 283 deletions

66
.gitignore vendored
View file

@ -1,44 +1,64 @@
# ── Dependencies ──
/node_modules/
/vendor/
/schneespur/node_modules/
# Dependencies
/schneespur/vendor/
/node_modules/
# ── Local env (keep .env.example tracked) ──
# Environment
.env
.env.*
!.env.example
.env.backup
.env.production
# ── Laravel runtime ──
# IDE / Editor
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Laravel
/schneespur/storage/logs/*.log
/schneespur/storage/framework/cache/*
/schneespur/storage/framework/sessions/*
/schneespur/storage/framework/views/*
/schneespur/storage/testing/
# ── Build artifacts ──
# Build artifacts
/schneespur/public/build/
/schneespur/public/hot
/release/
# ── Internal dev files (not part of distributable app) ──
/build.sh
/moduldoku.md
/package-lock.json
# ── Test caches ──
# Testing
.phpunit.result.cache
.phpunit.cache/
# ── IDE / OS ──
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
# Release builds
/release/
# ── Local tooling ──
# Misc
*.bak
*.orig
# ── GSD baseline (auto-generated) ──
.gsd
.gsd-id
.mcp.json
.bg-shell/
*~
*.code-workspace
.env.*
!.env.example
node_modules/
.next/
dist/
build/
__pycache__/
*.pyc
.venv/
venv/
target/
vendor/
*.log
coverage/
.cache/
tmp/

215
build.sh Executable file
View file

@ -0,0 +1,215 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:-1.0.0}"
PRODUCT="schneespur"
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
SOURCE_DIR="${PROJECT_DIR}/${PRODUCT}"
BUILD_DIR="${PROJECT_DIR}/release/${PRODUCT}-${VERSION}"
ZIP_FILE="${PROJECT_DIR}/release/${PRODUCT}-${VERSION}.zip"
echo "════════════════════════════════════════════"
echo " Building ${PRODUCT} v${VERSION}"
echo "════════════════════════════════════════════"
cd "$PROJECT_DIR"
# ── Clean previous build ──
rm -rf "$BUILD_DIR" "$ZIP_FILE"
mkdir -p "$BUILD_DIR"
# ── 1. Frontend build ──
echo ""
echo "▸ Installing npm dependencies..."
(cd "$SOURCE_DIR" && (npm ci --silent 2>/dev/null || npm install --silent))
echo "▸ Building frontend assets..."
(cd "$SOURCE_DIR" && npm run build)
# ── 2. Composer production install ──
echo ""
echo "▸ Installing composer dependencies (production)..."
(cd "$SOURCE_DIR" && composer install --no-dev --optimize-autoloader --no-interaction --quiet)
# ── 3. Copy files ──
echo ""
echo "▸ Copying project files..."
# Core Laravel → build root (flat layout, like 1.0.0)
cp "$SOURCE_DIR/artisan" "$BUILD_DIR/"
cp "$SOURCE_DIR/composer.json" "$BUILD_DIR/"
cp "$SOURCE_DIR/composer.lock" "$BUILD_DIR/"
cp "$SOURCE_DIR/.env.example" "$BUILD_DIR/"
cp "$SOURCE_DIR/.editorconfig" "$BUILD_DIR/" 2>/dev/null || true
cp "$SOURCE_DIR/.htaccess" "$BUILD_DIR/" 2>/dev/null || true
# Application code → build root
cp -r "$SOURCE_DIR/app" "$BUILD_DIR/"
cp -r "$SOURCE_DIR/bootstrap" "$BUILD_DIR/"
cp -r "$SOURCE_DIR/config" "$BUILD_DIR/"
cp -r "$SOURCE_DIR/database" "$BUILD_DIR/"
cp -r "$SOURCE_DIR/lang" "$BUILD_DIR/"
cp -r "$SOURCE_DIR/public" "$BUILD_DIR/"
cp -r "$SOURCE_DIR/resources" "$BUILD_DIR/"
cp -r "$SOURCE_DIR/routes" "$BUILD_DIR/"
cp -r "$SOURCE_DIR/vendor" "$BUILD_DIR/"
# Modules directory — example module is dev-only and excluded from releases
if [ -d "$SOURCE_DIR/modules" ]; then
cp -r "$SOURCE_DIR/modules" "$BUILD_DIR/"
rm -rf "$BUILD_DIR/modules/example"
fi
# Documentation and legal → build root (flat, alongside code)
cp "$PROJECT_DIR/README.md" "$BUILD_DIR/" 2>/dev/null || true
cp "$PROJECT_DIR/LICENSE" "$BUILD_DIR/" 2>/dev/null || true
cp "$PROJECT_DIR/INSTALL.de.md" "$BUILD_DIR/" 2>/dev/null || true
cp "$PROJECT_DIR/INSTALL.en.md" "$BUILD_DIR/" 2>/dev/null || true
# ── 4. Prepare storage structure (empty, writable) ──
echo "▸ Preparing storage structure..."
rm -rf "$BUILD_DIR/storage"
mkdir -p "$BUILD_DIR/storage/app/private"
mkdir -p "$BUILD_DIR/storage/app/public"
mkdir -p "$BUILD_DIR/storage/framework/cache/data"
mkdir -p "$BUILD_DIR/storage/framework/sessions"
mkdir -p "$BUILD_DIR/storage/framework/testing"
mkdir -p "$BUILD_DIR/storage/framework/views"
mkdir -p "$BUILD_DIR/storage/logs"
for dir in "$BUILD_DIR/storage/app/private" \
"$BUILD_DIR/storage/app/public" \
"$BUILD_DIR/storage/framework/cache/data" \
"$BUILD_DIR/storage/framework/sessions" \
"$BUILD_DIR/storage/framework/testing" \
"$BUILD_DIR/storage/framework/views" \
"$BUILD_DIR/storage/logs"; do
touch "$dir/.gitkeep"
done
# ── 5. Remove dev/unnecessary files ──
echo "▸ Cleaning up dev files..."
# Remove installed.lock (fresh install!)
rm -f "$BUILD_DIR/storage/app/installed.lock"
# Remove public/storage symlink (installer creates it)
rm -f "$BUILD_DIR/public/storage"
# Remove bootstrap cache (regenerated on first run)
rm -f "$BUILD_DIR/bootstrap/cache/"*.php 2>/dev/null || true
# Remove test files
rm -rf "$BUILD_DIR/tests"
# Remove dev config/tooling
rm -f "$BUILD_DIR/phpunit.xml"
rm -f "$BUILD_DIR/.styleci.yml"
rm -f "$BUILD_DIR/.gitignore"
rm -f "$BUILD_DIR/.gitattributes"
rm -f "$BUILD_DIR/package.json"
rm -f "$BUILD_DIR/package-lock.json"
rm -f "$BUILD_DIR/vite.config.js"
# Remove GSD/AI/editor artifacts
rm -rf "$BUILD_DIR/.gsd"
rm -f "$BUILD_DIR/.gsd-id"
rm -f "$BUILD_DIR/CLAUDE.md"
rm -f "$BUILD_DIR/gpt.md"
rm -f "$BUILD_DIR/module.md"
rm -f "$BUILD_DIR/site.md"
rm -f "$BUILD_DIR/.mcp.json"
# Remove test/scratch files
rm -f "$BUILD_DIR/test.txt"
rm -f "$BUILD_DIR/test-file.txt"
rm -f "$BUILD_DIR/app/test.php"
# Remove database factories/seeders (not needed in production)
rm -rf "$BUILD_DIR/database/factories"
rm -rf "$BUILD_DIR/database/seeders"
# ── 6. Slim vendor directory ──
echo "▸ Slimming vendor directory..."
# Remove .git directories from vendor packages (source installs)
find "$BUILD_DIR/vendor" -type d -name ".git" -exec rm -rf {} + 2>/dev/null || true
# Remove tests, docs, and other non-runtime directories
find "$BUILD_DIR/vendor" -type d \( \
-name "tests" -o \
-name "Tests" -o \
-name "test" -o \
-name "Test" -o \
-name "test_files" -o \
-name "docs" -o \
-name "doc" -o \
-name "examples" -o \
-name "example" -o \
-name ".github" \
\) -exec rm -rf {} + 2>/dev/null || true
# Remove non-runtime files
find "$BUILD_DIR/vendor" -type f \( \
-name "*.md" -o \
-name "*.markdown" -o \
-name "CHANGELOG*" -o \
-name "CHANGE_LOG*" -o \
-name "CHANGES*" -o \
-name "UPGRADING*" -o \
-name "UPGRADE*" -o \
-name "SECURITY*" -o \
-name "CONTRIBUTING*" -o \
-name "CODE_OF_CONDUCT*" -o \
-name ".editorconfig" -o \
-name ".gitignore" -o \
-name ".gitattributes" -o \
-name ".php-cs-fixer*" -o \
-name ".php_cs*" -o \
-name "phpunit.xml*" -o \
-name "phpstan*" -o \
-name ".styleci.yml" -o \
-name ".travis.yml" -o \
-name "Makefile" -o \
-name "Dockerfile" -o \
-name "docker-compose*" \
\) -delete 2>/dev/null || true
# ── 7. Write version file ──
echo "▸ Writing version file..."
cat > "$BUILD_DIR/VERSION" << EOF
${VERSION}
EOF
# ── 8. Write initial update state (prevents self-update to same version) ──
echo "▸ Writing initial update state..."
cat > "$BUILD_DIR/storage/app/schneespur_update_state.json" << EOF
{"current_version":"${VERSION}","last_counter":1}
EOF
# ── 9. Create ZIP ──
echo ""
echo "▸ Creating ZIP archive..."
rm -f "${ZIP_FILE}.filepart"
(cd "${BUILD_DIR}" && zip -r -q "${ZIP_FILE}" .)
# ── 10. Summary ──
ZIP_SIZE=$(du -h "$ZIP_FILE" | cut -f1)
FILE_COUNT=$(find "$BUILD_DIR" -type f | wc -l)
echo ""
echo "════════════════════════════════════════════"
echo " ✓ Build complete!"
echo ""
echo " Version: ${VERSION}"
echo " Files: ${FILE_COUNT}"
echo " ZIP: ${ZIP_FILE} (${ZIP_SIZE})"
echo "════════════════════════════════════════════"
echo ""
echo " The ZIP is ready for distribution."
echo " Users: unzip → FTP upload → open in browser → installer starts"
# ── 11. Restore dev dependencies ──
echo ""
echo "▸ Restoring dev composer dependencies..."
(cd "$SOURCE_DIR" && composer install --no-interaction --quiet)

1046
moduldoku.md Normal file

File diff suppressed because it is too large Load diff

7283
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:dGVzdGluZ2tleXRlc3RpbmdrZXl0ZXN0aW5na2V5cw=="/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
<env name="CACHE_STORE" value="array"/>
</php>
</phpunit>

View file

@ -1,17 +0,0 @@
<?php
namespace Tests;
use Illuminate\Contracts\Console\Kernel;
trait CreatesApplication
{
public function createApplication(): \Illuminate\Foundation\Application
{
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
}

View file

@ -1,202 +0,0 @@
<?php
namespace Tests\Feature;
use App\Services\Extension\FilterRegistry;
use App\Services\Extension\NavigationRegistry;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
class FilterRegistryTest extends TestCase
{
use LazilyRefreshDatabase;
private FilterRegistry $registry;
protected function setUp(): void
{
parent::setUp();
$this->registry = new FilterRegistry;
}
public function test_apply_returns_original_value_when_no_filters_registered(): void
{
$result = $this->registry->apply('schneespur.nonexistent.hook', ['a', 'b']);
$this->assertSame(['a', 'b'], $result);
}
public function test_filters_execute_in_priority_order(): void
{
$order = [];
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'priority-200';
return $value;
}, 200);
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'priority-50';
return $value;
}, 50);
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'priority-100';
return $value;
}, 100);
$this->registry->apply('test.hook', 'start');
$this->assertSame(['priority-50', 'priority-100', 'priority-200'], $order);
}
public function test_equal_priority_preserves_registration_order(): void
{
$order = [];
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'first';
return $value;
}, 100);
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'second';
return $value;
}, 100);
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'third';
return $value;
}, 100);
$this->registry->apply('test.hook', 'start');
$this->assertSame(['first', 'second', 'third'], $order);
}
public function test_apply_passes_context_to_callbacks(): void
{
$receivedContext = [];
$this->registry->register('test.hook', function ($value, $ctxA, $ctxB) use (&$receivedContext) {
$receivedContext = [$ctxA, $ctxB];
return $value;
});
$this->registry->apply('test.hook', 'value', 'context-a', 'context-b');
$this->assertSame(['context-a', 'context-b'], $receivedContext);
}
public function test_throwing_callback_logs_warning_and_preserves_previous_value(): void
{
Log::shouldReceive('warning')
->once()
->withArgs(function ($message, $context) {
return $message === 'FilterRegistry: callback failed'
&& $context['hook'] === 'test.hook'
&& str_contains($context['error'], 'Intentional test exception');
});
$this->registry->register('test.hook', function (array $value) {
$value[] = 'before-error';
return $value;
}, 10);
$this->registry->register('test.hook', function ($value) {
throw new \RuntimeException('Intentional test exception');
}, 20);
$this->registry->register('test.hook', function (array $value) {
$value[] = 'after-error';
return $value;
}, 30);
$result = $this->registry->apply('test.hook', []);
$this->assertSame(['before-error', 'after-error'], $result);
}
public function test_callbacks_can_transform_value_through_chain(): void
{
$this->registry->register('test.hook', function (int $value) {
return $value + 10;
}, 10);
$this->registry->register('test.hook', function (int $value) {
return $value * 2;
}, 20);
$result = $this->registry->apply('test.hook', 5);
$this->assertSame(30, $result);
}
public function test_single_callback_executes(): void
{
$this->registry->register('test.hook', function (array $items) {
$items[] = 'added';
return $items;
});
$result = $this->registry->apply('test.hook', ['original']);
$this->assertSame(['original', 'added'], $result);
}
public function test_navigation_items_hook_is_applied(): void
{
$this->bootExampleModule();
$nav = $this->app->make(NavigationRegistry::class);
$nav->addGroup('modules', 'Modules', 100);
$items = $nav->getItems();
$allSlugs = [];
foreach ($items as $groupItems) {
foreach ($groupItems as $item) {
$allSlugs[] = $item['slug'];
}
}
$this->assertContains('example-filter', $allSlugs);
}
public function test_dashboard_kpis_hook_is_applied(): void
{
$this->bootExampleModule();
$filterRegistry = $this->app->make(FilterRegistry::class);
$widgets = $filterRegistry->apply('schneespur.dashboard.kpis', [
['key' => 'original-widget', 'label' => 'Original'],
]);
$keys = array_column($widgets, 'key');
$this->assertContains('example-filter-widget', $keys);
}
private function bootExampleModule(): void
{
$modulePath = base_path('modules/example/src');
spl_autoload_register(function (string $class) use ($modulePath) {
$prefix = 'Schneespur\\Module\\Example\\';
if (! str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $modulePath . '/' . str_replace('\\', '/', $relative) . '.php';
if (file_exists($file)) {
require_once $file;
}
});
putenv('EXAMPLE_MODULE_ENABLED=true');
$_ENV['EXAMPLE_MODULE_ENABLED'] = true;
$this->app->register(\Schneespur\Module\Example\ExampleServiceProvider::class);
}
}

View file

@ -1,14 +0,0 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
}