Compare commits

...

8 commits

Author SHA1 Message Date
Ruben
7782eefa96 Add trailing slash redirect for list routes 2026-01-17 00:03:53 +01:00
Ruben
0901d6324c Update ParsedownExtra to use mb_encode_numericentity for safer HTML
encoding
2026-01-16 22:03:31 +01:00
Ruben
7b5d07a88d Add ISO date format for fallback date extraction
Convert fallback date to ISO format before plugin processing
2026-01-16 22:03:25 +01:00
Ruben
d446ab1896 Optimize content rendering and plugin loading order
Load metadata and plugins before content rendering to ensure
plugin-provided variables are available to PHP content files
2026-01-16 22:03:20 +01:00
Ruben
d54cdc7ce1 Add support for INI file sections in translations
Flatten INI sections into dot notation keys for easier access
Improve language file filtering with pattern matching
2026-01-16 22:03:15 +01:00
Ruben
ccfca94b57 Add performance profiling tools and test data generator
Add Xdebug configuration and container setup for performance profiling
Add comprehensive performance testing script with profiling and test
data generation
Add test data statistics and cleanup commands
Add documentation for all performance testing features
2026-01-14 14:40:57 +01:00
Ruben
47cf4d8652 Cleanup 2026-01-14 14:40:44 +01:00
Ruben
1390ed51e8 Compress instructions 2026-01-14 14:40:30 +01:00
9 changed files with 424 additions and 140 deletions

114
AGENT.md
View file

@ -1,106 +1,22 @@
# PnP Development Guidelines
## Philosophy ## Philosophy
Minimal PHP for modern conveniences. Prioritize longevity (decade-scale maintainability) by avoiding volatile dependencies. Strictly add only what's essential—readable, simple, and future-proof.
**Just enough, nothing more.** This framework applies minimal PHP to enable modern conveniences while remaining maintainable for years or decades. Avoid rapidly changing components and dependencies. The code should be readable, simple, and only add what is strictly necessary. ## Core Constraints
**Minimalism:** Only essential tech (HTML, PHP 8.4+, CSS). No JS, frameworks, build tools, or package managers. Comments only for major sections.
## Core Principles **Frontend:**
- Classless, semantic HTML5
- Modern CSS: nesting, `oklch()`, grid, `clamp()`, logical props
- Responsive via fluid typography + flexible layouts
### Minimalism **Security:**
- Use only what is strictly necessary to achieve the goal - Path validation blocks traversal
- No frameworks, no build tools, no package managers for frontend code - Files restricted to document root
- Avoid abstractions unless they provide clear, lasting value - Strict MIME types + no direct user-input execution
- Sparse commenting—only mark main sections
### Technology Stack ## Code Style
- **Allowed:** HTML, PHP (8.4+), CSS **PHP:** Modern syntax (arrow functions, null coalescing, match). Type hints where practical. Ternary for simple conditionals. Single-purpose functions.
- **Not allowed:** JavaScript
- Use modern PHP features when they improve readability or performance
- Leverage modern CSS features for smart, efficient styling
### File-Based Routing **CSS:** Variables, native nesting, grid layouts. `clamp()` over `@media`. Relative units > pixels.
- Folder hierarchy dictates URL structure
- Drop a file (`.md`, `.php`, `.html`) in a folder and it renders immediately
- Assets placed in content directories are automatically accessible
- Directories with subfolders trigger list views
### Template System **Templates:** Escape output (`htmlspecialchars()` for UGC). Short echo tags (`<?= $var ?>`).
- Custom templates override defaults (never modify defaults)
- Custom templates live in `/app/custom/`
- Default templates provide fallback behavior
- Templates use PHP includes—simple and straightforward
### Content Conventions
#### File Naming
- Cover images: `cover.jpg`, `cover.webp`, etc.
#### Date Formatting
- Folder names can include dates: `YYYY-MM-DD-title`
- Dates are automatically extracted and formatted (Norwegian format: "23. oktober 2025")
- Metadata can override automatic date detection
#### Metadata
- Use `metadata.ini` files for structured data
- Common fields: `title`, `date`, `summary`
- Metadata overrides automatic title/date extraction
### HTML & CSS Standards
- Classless CSS where possible
- HTML should be highly compliant with best practices
- Use semantic HTML5 elements
- Modern CSS features: custom properties, nesting, `oklch()` colors, grid, clamp, logical properties
- Responsive by default using fluid typography and flexible layouts
### Security
- Path validation prevents directory traversal
- Files must be within document root
- MIME types properly set for all served content
- No direct execution of arbitrary user input
### Code Style
#### PHP
- Modern syntax: arrow functions, null coalescing, match expressions
- Use type hints when practical
- Ternary operators for simple conditionals
- Keep functions focused and single-purpose
#### CSS
- Use CSS variables for theming
- Nesting for component-scoped styles
- Grid for layout, not tables or excessive flexbox
- `clamp()` for responsive sizing
- Avoid pixel values where relative units work better
#### Templates
- Escape output: `htmlspecialchars()` for user-generated content
- Short echo tags: `<?= $var ?>`
- Minimize logic in templates—prepare data beforehand
### Performance Considerations
- Page load time displayed in footer (transparency and pride in performance)
- CSS versioned with MD5 hash for cache busting
- Minimal HTTP requests through direct includes
- No JavaScript means no parsing delay
### Extensibility Patterns
- Third-party code goes in `/app/vendor/`
- Custom code goes in `/app/custom/`
### List Views
When a directory contains subdirectories:
- Default list template generates automatic listings
- Each item shows title, date, optional cover image, optional summary
- Override with custom list template for specialized presentation
## What This Framework Is Not
- Not a CMS with an admin panel
- Not a single-page application
- Not a JavaScript framework
## What This Framework Is
- A simple, intuitive way to publish content
- A foundation that will work for decades

View file

@ -1,4 +1,3 @@
/* Variables */
:root { :root {
--color-text: oklch(20% 0 0); --color-text: oklch(20% 0 0);
--color-background: oklch(98% 0 0); --color-background: oklch(98% 0 0);
@ -6,18 +5,17 @@
--color-accent-light: oklch(95% 0.05 250); --color-accent-light: oklch(95% 0.05 250);
--color-border: oklch(85% 0 0); --color-border: oklch(85% 0 0);
--color-muted: oklch(50% 0 0); --color-muted: oklch(50% 0 0);
--space-xs: 0.5rem; --space-xs: 0.5rem;
--space-s: 1rem; --space-s: 1rem;
--space-m: 2rem; --space-m: 2rem;
--space-l: 4rem; --space-l: 4rem;
--size-content: 65ch; --size-content: 65ch;
--size-constrained: 42rem; --size-constrained: 42rem;
--size-wide: 90rem; --size-wide: 90rem;
} }
/* Base */
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -32,7 +30,7 @@ html {
body { body {
margin: 0; margin: 0;
display: grid; display: grid;
grid-template-columns: grid-template-columns:
[full-start] minmax(var(--space-s), 1fr) [full-start] minmax(var(--space-s), 1fr)
[content-start] minmax(0, var(--size-constrained)) [content-start] minmax(0, var(--size-constrained))
[content-end] minmax(var(--space-s), 1fr) [content-end] minmax(var(--space-s), 1fr)
@ -41,7 +39,6 @@ body {
min-height: 100vh; min-height: 100vh;
} }
/* Typography */
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
line-height: 1.2; line-height: 1.2;
margin-block: 1.5em 0.5em; margin-block: 1.5em 0.5em;
@ -56,17 +53,15 @@ p, ul, ol, dl {
margin-block: 1em; margin-block: 1em;
} }
/* Links */
a { a {
color: var(--color-accent); color: var(--color-accent);
text-underline-offset: 0.2em; text-underline-offset: 0.2em;
&:hover { &:hover {
text-decoration: none; text-decoration: none;
} }
} }
/* Layout */
header, main, footer { header, main, footer {
grid-column: content; grid-column: content;
} }
@ -78,29 +73,29 @@ header {
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-s); gap: var(--space-s);
align-items: center; align-items: center;
& nav { & nav {
display: flex; display: flex;
gap: var(--space-s); gap: var(--space-s);
flex-wrap: wrap; flex-wrap: wrap;
&:first-child { &:first-child {
flex: 1; flex: 1;
} }
& a { & a {
text-decoration: none; text-decoration: none;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
&[aria-current] { &[aria-current] {
font-weight: bold; font-weight: bold;
} }
} }
} }
& .language-switcher { & .language-switcher {
margin-inline-start: auto; margin-inline-start: auto;
} }
@ -115,26 +110,24 @@ footer {
padding-block: var(--space-s); padding-block: var(--space-s);
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-muted); color: var(--color-muted);
& nav { & nav {
display: flex; display: flex;
gap: var(--space-s); gap: var(--space-s);
margin-block-end: var(--space-xs); margin-block-end: var(--space-xs);
} }
& p { & p {
margin: 0; margin: 0;
} }
} }
/* Images */
img { img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
display: block; display: block;
} }
/* Code */
code { code {
background: var(--color-accent-light); background: var(--color-accent-light);
padding: 0.125em 0.25em; padding: 0.125em 0.25em;
@ -147,14 +140,13 @@ pre {
padding: var(--space-s); padding: var(--space-s);
border-radius: 0.5em; border-radius: 0.5em;
overflow-x: auto; overflow-x: auto;
& code { & code {
background: none; background: none;
padding: 0; padding: 0;
} }
} }
/* Tables */
table { table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
@ -171,7 +163,6 @@ th {
font-weight: bold; font-weight: bold;
} }
/* Blockquote */
blockquote { blockquote {
margin-inline: 0; margin-inline: 0;
padding-inline-start: var(--space-s); padding-inline-start: var(--space-s);
@ -179,7 +170,6 @@ blockquote {
color: var(--color-muted); color: var(--color-muted);
} }
/* Article metadata */
article { article {
& time { & time {
color: var(--color-muted); color: var(--color-muted);

View file

@ -77,15 +77,38 @@ function loadTranslations(string $lang): array {
$defaultFile = dirname(__DIR__, 2) . "/default/languages/$lang.ini"; $defaultFile = dirname(__DIR__, 2) . "/default/languages/$lang.ini";
$customFile = dirname(__DIR__, 3) . "/custom/languages/$lang.ini"; $customFile = dirname(__DIR__, 3) . "/custom/languages/$lang.ini";
$translations = file_exists($defaultFile) ? parse_ini_file($defaultFile) ?: [] : []; $translations = file_exists($defaultFile) ? flattenIniSections(parse_ini_file($defaultFile, true) ?: []) : [];
if (file_exists($customFile)) { if (file_exists($customFile)) {
$translations = array_merge($translations, parse_ini_file($customFile) ?: []); $translations = array_merge($translations, flattenIniSections(parse_ini_file($customFile, true) ?: []));
} }
return $translations; return $translations;
} }
/**
* Flatten INI sections into dot notation keys
* [section] key = value becomes section.key = value
* Top-level keys without sections are preserved as-is
*/
function flattenIniSections(array $ini): array {
$result = [];
foreach ($ini as $key => $value) {
if (is_array($value)) {
// This is a section - prefix all keys with section name
foreach ($value as $subKey => $subValue) {
$result["$key.$subKey"] = $subValue;
}
} else {
// Top-level key without section
$result[$key] = $value;
}
}
return $result;
}
function formatDate(string $dateString, string $lang): string { function formatDate(string $dateString, string $lang): string {
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $dateString, $m)) { if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $dateString, $m)) {
return $dateString; return $dateString;
@ -116,10 +139,13 @@ function filterFilesByLanguage(array $files, string $dir, Context $ctx): array {
foreach ($files as $file) { foreach ($files as $file) {
$parts = explode('.', $file['name']); $parts = explode('.', $file['name']);
// Language-specific file (name.lang.ext) // Language-specific file (name.lang.ext) - detect by 2-letter code pattern
if (count($parts) >= 3 && in_array($parts[count($parts) - 2], $availableLangs)) { $potentialLang = $parts[count($parts) - 2] ?? '';
$fileLang = $parts[count($parts) - 2]; $isLangFile = count($parts) >= 3 && preg_match('/^[a-z]{2}$/', $potentialLang);
if ($fileLang === $currentLang) {
if ($isLangFile) {
// Only include if it matches current language AND is available
if ($potentialLang === $currentLang && in_array($potentialLang, $availableLangs)) {
$filtered[] = $file; $filtered[] = $file;
$seen[$parts[0]] = true; $seen[$parts[0]] = true;
} }

View file

@ -20,7 +20,7 @@ function renderContentFile(string $filePath, ?Context $ctx = null): string {
if (!class_exists('ParsedownExtra')) { if (!class_exists('ParsedownExtra')) {
require_once __DIR__ . '/vendor/ParsedownExtra.php'; require_once __DIR__ . '/vendor/ParsedownExtra.php';
} }
$html = '<article>' . (new ParsedownExtra())->text(file_get_contents($filePath)) . '</article>'; $html = (new ParsedownExtra())->text(file_get_contents($filePath));
if ($langPrefix) { if ($langPrefix) {
$html = preg_replace( $html = preg_replace(
@ -73,13 +73,14 @@ function renderFile(Context $ctx, string $filePath): void {
$ext = pathinfo($realPath, PATHINFO_EXTENSION); $ext = pathinfo($realPath, PATHINFO_EXTENSION);
if (in_array($ext, CONTENT_EXTENSIONS)) { if (in_array($ext, CONTENT_EXTENSIONS)) {
$content = renderContentFile($realPath, $ctx); // Load metadata and page plugins BEFORE rendering content
// so that plugin-provided template variables are available to PHP content files
$pageDir = dirname($realPath); $pageDir = dirname($realPath);
$pageMetadata = loadMetadata($pageDir); $pageMetadata = loadMetadata($pageDir);
getPluginManager()->loadPagePlugins($pageMetadata); getPluginManager()->loadPagePlugins($pageMetadata);
$content = renderContentFile($realPath, $ctx);
$navigation = $ctx->navigation; $navigation = $ctx->navigation;
$homeLabel = $ctx->homeLabel; $homeLabel = $ctx->homeLabel;
$pageTitle = $pageMetadata['title'] ?? null; $pageTitle = $pageMetadata['title'] ?? null;
@ -122,15 +123,16 @@ function renderFile(Context $ctx, string $filePath): void {
} }
function renderMultipleFiles(Context $ctx, array $files, string $pageDir): void { function renderMultipleFiles(Context $ctx, array $files, string $pageDir): void {
// Load metadata and page plugins BEFORE rendering content files
// so that plugin-provided template variables are available to PHP content files
$pageMetadata = loadMetadata($pageDir);
getPluginManager()->loadPagePlugins($pageMetadata);
$content = ''; $content = '';
foreach ($files as $file) { foreach ($files as $file) {
$content .= renderContentFile($file, $ctx); $content .= renderContentFile($file, $ctx);
} }
$pageMetadata = loadMetadata($pageDir);
getPluginManager()->loadPagePlugins($pageMetadata);
$navigation = $ctx->navigation; $navigation = $ctx->navigation;
$homeLabel = $ctx->homeLabel; $homeLabel = $ctx->homeLabel;
$pageTitle = $pageMetadata['title'] ?? null; $pageTitle = $pageMetadata['title'] ?? null;

View file

@ -82,6 +82,12 @@ switch ($parsedPath['type']) {
case 'list': case 'list':
$dir = $parsedPath['path']; $dir = $parsedPath['path'];
// Redirect to add trailing slash if needed
if (!$ctx->hasTrailingSlash) {
header('Location: ' . rtrim($_SERVER['REQUEST_URI'], '/') . '/', true, 301);
exit;
}
// Check for page content files in this directory // Check for page content files in this directory
$pageContent = null; $pageContent = null;
$contentFiles = findAllContentFiles($dir); $contentFiles = findAllContentFiles($dir);
@ -133,7 +139,14 @@ switch ($parsedPath['type']) {
// Let plugins format date // Let plugins format date
$date = Hooks::apply(Hook::PROCESS_CONTENT, $date, 'date_format'); $date = Hooks::apply(Hook::PROCESS_CONTENT, $date, 'date_format');
} else { } else {
$date = extractDateFromFolder($item) ?: date("F d, Y", filemtime($itemPath)); $extractedDate = extractDateFromFolder($item);
if ($extractedDate) {
$date = $extractedDate;
} else {
// Convert timestamp to ISO format and let plugins format it
$isoDate = date("Y-m-d", filemtime($itemPath));
$date = Hooks::apply(Hook::PROCESS_CONTENT, $isoDate, 'date_format');
}
} }
// Use slug if available // Use slug if available

View file

@ -625,7 +625,7 @@ class ParsedownExtra extends Parsedown
$DOMDocument = new DOMDocument; $DOMDocument = new DOMDocument;
# http://stackoverflow.com/q/11309194/200145 # http://stackoverflow.com/q/11309194/200145
$elementMarkup = mb_convert_encoding($elementMarkup, 'HTML-ENTITIES', 'UTF-8'); $elementMarkup = mb_encode_numericentity($elementMarkup, [0x80, 0x10FFFF, 0, ~0], 'UTF-8');
# http://stackoverflow.com/q/4879946/200145 # http://stackoverflow.com/q/4879946/200145
$DOMDocument->loadHTML($elementMarkup); $DOMDocument->loadHTML($elementMarkup);
@ -683,4 +683,4 @@ class ParsedownExtra extends Parsedown
# #
protected $regexAttribute = '(?:[#.][-\w]+[ ]*)'; protected $regexAttribute = '(?:[#.][-\w]+[ ]*)';
} }

14
devel/Containerfile Normal file
View file

@ -0,0 +1,14 @@
FROM php:8.4.14-apache
# Install Xdebug for performance profiling
RUN pecl install xdebug-3.5.0 && \
docker-php-ext-enable xdebug
# Configure Xdebug for profiling
RUN echo "xdebug.mode=develop,profile" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.output_dir=/tmp" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.start_with_request=trigger" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.profiler_output_name=cachegrind.out.%p" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
# Note: To trigger profiling, add ?XDEBUG_PROFILE=1 to any URL
# Profile files will be generated in /tmp/ inside the container

View file

@ -15,7 +15,7 @@ services:
# - "4040:80" # - "4040:80"
# command: bash -c "a2enconf custom && a2enmod rewrite && apache2-foreground" # command: bash -c "a2enconf custom && a2enmod rewrite && apache2-foreground"
default: default:
image: php:8.4.14-apache build: ./
container_name: folderweb-default container_name: folderweb-default
working_dir: /var/www/html/ working_dir: /var/www/html/
volumes: volumes:

323
devel/perf.sh Executable file
View file

@ -0,0 +1,323 @@
#!/bin/bash
# FolderWeb Performance Testing Tool
# All-in-one script for profiling, test data generation, and analysis
set -e
CONTAINER="${CONTAINER:-folderweb-default}"
PORT="${PORT:-8080}"
ANALYZER="/tmp/analyze_profile.php"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
show_help() {
cat << EOF
FolderWeb Performance Testing Tool
SETUP (First Time):
cd devel && podman-compose build && podman-compose up -d
PROFILING COMMANDS:
profile <url> Profile a URL (add ?XDEBUG_PROFILE=1 and analyze)
analyze [file] Analyze a cachegrind file (latest if not specified)
list List all available cachegrind files
clean Remove all cachegrind profile files
TEST DATA COMMANDS:
generate <size> Generate synthetic test data in /tmp/test-content
Sizes: small (~100 posts), medium (~500), large (~1500), huge (~5000+)
generate custom N M D Custom: N categories, M posts/cat, D depth
testdata-stats Show test data statistics
testdata-clean Remove test data from container
USING TEST DATA:
Test data is generated at /tmp/test-content inside the container.
Option 1 - Symlink (quick):
podman exec folderweb-default ln -s /tmp/test-content /var/www/html
Option 2 - Edit router.php (temporary):
Change \$ctx->contentDir to '/tmp/test-content'
EXAMPLES:
$0 profile / # Profile homepage
$0 generate medium # Generate ~500 posts
$0 testdata-stats # View stats
$0 profile /blog-321/ # Profile test data
# Stress test with huge dataset
$0 generate huge && $0 profile /
# Export for KCachegrind
podman cp folderweb-default:/tmp/cachegrind.out.123 ./profile.out
kcachegrind ./profile.out
ENVIRONMENT VARIABLES:
CONTAINER=name Target container (default: folderweb-default)
PORT=port Target port (default: 8080)
RESULTS:
Focus on functions taking >5% of execution time.
Time (ms) = total time spent | Memory (KB) = total allocated
Calls = invocation count | Percentage = % of total time
EOF
}
ensure_analyzer() {
if ! podman exec "$CONTAINER" test -f "$ANALYZER" 2>/dev/null; then
echo -e "${YELLOW}Installing profile analyzer...${NC}" >&2
podman exec "$CONTAINER" bash -c "cat > $ANALYZER" << 'EOFANALYZER'
<?php
if ($argc < 2) { echo "Usage: php analyze_profile.php <cachegrind_file>\n"; exit(1); }
$file = $argv[1];
if (!file_exists($file)) { echo "Error: File not found: $file\n"; exit(1); }
$lines = file($file);
$functions = [];
$currentFile = '';
$currentFunction = '';
foreach ($lines as $line) {
$line = trim($line);
if (str_starts_with($line, 'fl=')) {
preg_match('/fl=\(?\d*\)?\s*(.+)/', $line, $matches);
$currentFile = $matches[1] ?? '';
} elseif (str_starts_with($line, 'fn=')) {
preg_match('/fn=\(?\d*\)?\s*(.+)/', $line, $matches);
$currentFunction = $matches[1] ?? '';
} elseif (preg_match('/^(\d+)\s+(\d+)\s+(\d+)/', $line, $matches)) {
$time = (int)$matches[2];
$memory = (int)$matches[3];
if ($currentFunction && $time > 0) {
if (!isset($functions[$currentFunction])) {
$functions[$currentFunction] = ['name' => $currentFunction, 'file' => $currentFile, 'time' => 0, 'memory' => 0, 'calls' => 0];
}
$functions[$currentFunction]['time'] += $time;
$functions[$currentFunction]['memory'] += $memory;
$functions[$currentFunction]['calls']++;
}
}
}
usort($functions, fn($a, $b) => $b['time'] <=> $a['time']);
echo "\n=== TOP 20 SLOWEST FUNCTIONS ===\n\n";
echo str_pad("Time (ms)", 12) . str_pad("Memory (KB)", 14) . str_pad("Calls", 8) . "Function\n";
echo str_repeat("-", 100) . "\n";
$total_time = array_sum(array_column($functions, 'time'));
foreach (array_slice($functions, 0, 20) as $fn) {
$time_ms = round($fn['time'] / 100000, 2);
$memory_kb = round($fn['memory'] / 1024, 2);
$percentage = round(($fn['time'] / $total_time) * 100, 1);
$funcName = strlen($fn['name']) > 60 ? substr($fn['name'], 0, 57) . '...' : $fn['name'];
echo str_pad($time_ms, 12) . str_pad($memory_kb, 14) . str_pad($fn['calls'], 8) . $funcName . " ({$percentage}%)\n";
if ($fn['file'] && $fn['file'] !== 'php:internal') {
echo str_repeat(" ", 34) . "└─ {$fn['file']}\n";
}
}
echo "\nTotal execution time: " . round($total_time / 100000, 2) . " ms\n\n";
EOFANALYZER
fi
}
cmd_profile() {
local url="${1:-/}"
echo -e "${GREEN}Profiling: $url (container: $CONTAINER, port: $PORT)${NC}"
local response=$(curl -s -I "http://localhost:${PORT}${url}?XDEBUG_PROFILE=1")
local profile_file=$(echo "$response" | grep -i "X-Xdebug-Profile-Filename" | cut -d' ' -f2 | tr -d '\r')
if [ -z "$profile_file" ]; then
echo -e "${RED}Error: No profile generated. Is Xdebug installed?${NC}"
echo -e "${YELLOW}Tip: Rebuild container with: cd devel && podman-compose build${NC}"
exit 1
fi
echo -e "${GREEN}Profile generated: $profile_file${NC}"
ensure_analyzer
echo ""
podman exec "$CONTAINER" php "$ANALYZER" "$profile_file"
}
cmd_analyze() {
local file="$1"
ensure_analyzer
if [ -z "$file" ]; then
file=$(podman exec "$CONTAINER" sh -c 'ls -t /tmp/cachegrind.out.* 2>/dev/null | head -1')
if [ -z "$file" ]; then
echo -e "${RED}No cachegrind files found. Run a profile first.${NC}"
exit 1
fi
echo -e "${YELLOW}Analyzing: $file${NC}"
fi
podman exec "$CONTAINER" php "$ANALYZER" "$file"
}
cmd_list() {
echo -e "${GREEN}Available cachegrind profiles in $CONTAINER:${NC}"
if ! podman exec "$CONTAINER" sh -c 'ls -lh /tmp/cachegrind.out.* 2>/dev/null'; then
echo -e "${YELLOW}No profile files found.${NC}"
fi
}
cmd_clean() {
echo -e "${YELLOW}Removing cachegrind files from $CONTAINER...${NC}"
podman exec "$CONTAINER" sh -c 'rm -f /tmp/cachegrind.out.*' 2>/dev/null || true
echo -e "${GREEN}Done!${NC}"
}
cmd_generate() {
local size="${1:-medium}"
local categories posts depth
case "$size" in
small) categories=5; posts=20; depth=2 ;;
medium) categories=10; posts=50; depth=3 ;;
large) categories=15; posts=100; depth=3 ;;
huge) categories=25; posts=200; depth=4 ;;
custom) categories=${2:-10}; posts=${3:-50}; depth=${4:-3} ;;
*) echo -e "${RED}Unknown size. Use: small, medium, large, huge, custom${NC}"; exit 1 ;;
esac
echo -e "${GREEN}Generating $size test data in $CONTAINER:/tmp/test-content${NC}"
echo -e "${BLUE}Config: $categories categories, $posts posts/category, $depth levels${NC}"
# Create generator script in container
cat << 'EOFGEN' | podman exec -i "$CONTAINER" tee /tmp/gen.php > /dev/null
<?php
$baseDir = '/tmp/test-content';
$numCategories = (int)($argv[1] ?? 10);
$postsPerCategory = (int)($argv[2] ?? 50);
$maxDepth = (int)($argv[3] ?? 3);
$titles = ['Getting Started with', 'Introduction to', 'Advanced Techniques in', 'Understanding', 'Best Practices for', 'Common Mistakes in', 'A Comprehensive Guide to', 'Mastering', 'Quick Tips for', 'The Ultimate Guide to', 'Exploring', 'Deep Dive into'];
$topics = ['Web Development', 'Performance Optimization', 'Database Design', 'API Integration', 'Security', 'Testing', 'Deployment', 'Caching Strategies', 'Microservices', 'Cloud Architecture', 'DevOps', 'Monitoring', 'Scalability', 'User Experience', 'Mobile Development', 'Machine Learning', 'Data Analysis'];
$categories = ['tutorials', 'guides', 'blog', 'documentation', 'examples', 'case-studies', 'best-practices', 'architecture', 'tools', 'resources'];
function generateMarkdown($title, $paragraphs = 5) {
$md = "# $title\n\n";
for ($i = 0; $i < $paragraphs; $i++) {
$sentences = rand(3, 6);
$paragraph = '';
for ($j = 0; $j < $sentences; $j++) {
$words = rand(10, 20);
$sentence = implode(' ', array_fill(0, $words, 'lorem ipsum dolor sit amet'));
$paragraph .= ucfirst($sentence) . '. ';
}
$md .= $paragraph . "\n\n";
if ($i % 2 == 0) $md .= "## Section " . ($i + 1) . "\n\n";
if ($i % 3 == 0) $md .= "- Bullet point one\n- Bullet point two\n- Bullet point three\n\n";
}
return $md;
}
function generateMetadata($title, $hasChildren = false) {
$ini = "[metadata]\ntitle = \"$title\"\nsummary = \"Summary for $title\"\ndate = " . date('Y-m-d', strtotime('-' . rand(1, 365) . ' days')) . "\n";
if ($hasChildren) { $ini .= "menu = 1\nmenu_order = " . rand(1, 10) . "\n"; }
return $ini;
}
function generateSlug($title) { return strtolower(preg_replace('/[^a-z0-9]+/i', '-', $title)); }
function generateContent($baseDir, $depth, $maxDepth, $postsPerCategory, $parentPath = '') {
global $titles, $topics, $categories;
if ($depth > $maxDepth) return;
$numCategories = $depth === 0 ? 10 : rand(3, 8);
for ($c = 0; $c < $numCategories; $c++) {
$categoryName = $categories[array_rand($categories)] . '-' . rand(100, 999);
$categoryPath = $baseDir . $parentPath . '/' . $categoryName;
if (!is_dir($categoryPath)) mkdir($categoryPath, 0755, true);
$categoryTitle = ucwords(str_replace('-', ' ', $categoryName));
file_put_contents("$categoryPath/metadata.ini", generateMetadata($categoryTitle, true));
if (rand(0, 1)) {
file_put_contents("$categoryPath/index.md", generateMarkdown("Welcome to $categoryTitle", 3));
}
$numPosts = $depth === $maxDepth ? rand(5, $postsPerCategory) : rand(5, 15);
for ($p = 0; $p < $numPosts; $p++) {
$title = $titles[array_rand($titles)] . ' ' . $topics[array_rand($topics)];
$date = date('Y-m-d', strtotime('-' . rand(1, 730) . ' days'));
$slug = generateSlug($title);
$postDir = "$categoryPath/$date-$slug";
if (!is_dir($postDir)) mkdir($postDir, 0755, true);
file_put_contents("$postDir/index.md", generateMarkdown($title, rand(3, 8)));
if (rand(0, 2) === 0) {
file_put_contents("$postDir/metadata.ini", generateMetadata($title, false));
}
}
if ($depth < $maxDepth && rand(0, 1)) {
generateContent($baseDir, $depth + 1, $maxDepth, $postsPerCategory, $parentPath . '/' . $categoryName);
}
}
}
if (is_dir($baseDir)) exec("rm -rf $baseDir");
mkdir($baseDir, 0755, true);
file_put_contents("$baseDir/00-intro.md", generateMarkdown("Test Site", 2));
echo "Generating test data...\n";
$start = microtime(true);
generateContent($baseDir, 0, $maxDepth, $postsPerCategory);
$duration = round(microtime(true) - $start, 2);
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($baseDir, RecursiveDirectoryIterator::SKIP_DOTS));
$fileCount = 0; foreach ($iterator as $file) if ($file->isFile()) $fileCount++;
echo "\nGenerated $fileCount files in {$duration}s\n";
echo "Location: $baseDir\n";
EOFGEN
# Run generator
podman exec "$CONTAINER" php /tmp/gen.php "$categories" "$posts" "$depth"
echo -e "\n${GREEN}Test data created at /tmp/test-content inside container${NC}"
echo -e "${YELLOW}To use: Update app/router.php to use /tmp/test-content${NC}"
echo -e "${YELLOW}Or profile directly: $0 profile /blog-123/${NC}"
}
cmd_testdata_stats() {
if ! podman exec "$CONTAINER" test -d /tmp/test-content 2>/dev/null; then
echo -e "${RED}No test data found in $CONTAINER:/tmp/test-content${NC}"
echo -e "${YELLOW}Generate with: $0 generate [size]${NC}"
exit 1
fi
echo -e "${GREEN}Test Data Statistics ($CONTAINER:/tmp/test-content):${NC}\n"
podman exec "$CONTAINER" bash -c '
cd /tmp/test-content
echo "Directories: $(find . -type d | wc -l)"
echo "Total files: $(find . -type f | wc -l)"
echo "Markdown files: $(find . -name "*.md" | wc -l)"
echo "Metadata files: $(find . -name "*.ini" | wc -l)"
echo "Total size: $(du -sh . | cut -f1)"
'
}
cmd_testdata_clean() {
echo -e "${YELLOW}Removing test data from $CONTAINER:/tmp/test-content...${NC}"
podman exec "$CONTAINER" rm -rf /tmp/test-content 2>/dev/null || true
echo -e "${GREEN}Done!${NC}"
}
# Main command dispatcher
case "${1:-help}" in
profile) cmd_profile "$2" ;;
analyze) cmd_analyze "$2" ;;
list) cmd_list ;;
clean) cmd_clean ;;
generate|gen) shift; cmd_generate "$@" ;;
testdata-stats|stats) cmd_testdata_stats ;;
testdata-clean) cmd_testdata_clean ;;
help|--help|-h) show_help ;;
*) echo -e "${RED}Unknown command: $1${NC}\n"; show_help; exit 1 ;;
esac