From 1390ed51e857bcfedd22ba2f7393f9cb6d2d71ab Mon Sep 17 00:00:00 2001 From: Ruben Date: Wed, 14 Jan 2026 14:40:30 +0100 Subject: [PATCH 1/8] Compress instructions --- AGENT.md | 114 ++++++++----------------------------------------------- 1 file changed, 15 insertions(+), 99 deletions(-) diff --git a/AGENT.md b/AGENT.md index 969c351..34d2d78 100644 --- a/AGENT.md +++ b/AGENT.md @@ -1,106 +1,22 @@ -# PnP Development Guidelines - ## 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 -- Use only what is strictly necessary to achieve the goal -- No frameworks, no build tools, no package managers for frontend code -- Avoid abstractions unless they provide clear, lasting value -- Sparse commenting—only mark main sections +**Security:** +- Path validation blocks traversal +- Files restricted to document root +- Strict MIME types + no direct user-input execution -### Technology Stack -- **Allowed:** HTML, PHP (8.4+), CSS -- **Not allowed:** JavaScript -- Use modern PHP features when they improve readability or performance -- Leverage modern CSS features for smart, efficient styling +## Code Style +**PHP:** Modern syntax (arrow functions, null coalescing, match). Type hints where practical. Ternary for simple conditionals. Single-purpose functions. -### File-Based Routing -- 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 +**CSS:** Variables, native nesting, grid layouts. `clamp()` over `@media`. Relative units > pixels. -### Template System -- 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: `` -- 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 +**Templates:** Escape output (`htmlspecialchars()` for UGC). Short echo tags (``). From 47cf4d865232f15e5e95ab52da08a165729d68ff Mon Sep 17 00:00:00 2001 From: Ruben Date: Wed, 14 Jan 2026 14:40:44 +0100 Subject: [PATCH 2/8] Cleanup --- app/default/styles/styles.css | 36 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/app/default/styles/styles.css b/app/default/styles/styles.css index 86203ca..2c8d029 100644 --- a/app/default/styles/styles.css +++ b/app/default/styles/styles.css @@ -1,4 +1,3 @@ -/* Variables */ :root { --color-text: oklch(20% 0 0); --color-background: oklch(98% 0 0); @@ -6,18 +5,17 @@ --color-accent-light: oklch(95% 0.05 250); --color-border: oklch(85% 0 0); --color-muted: oklch(50% 0 0); - + --space-xs: 0.5rem; --space-s: 1rem; --space-m: 2rem; --space-l: 4rem; - + --size-content: 65ch; --size-constrained: 42rem; --size-wide: 90rem; } -/* Base */ * { box-sizing: border-box; } @@ -32,7 +30,7 @@ html { body { margin: 0; display: grid; - grid-template-columns: + grid-template-columns: [full-start] minmax(var(--space-s), 1fr) [content-start] minmax(0, var(--size-constrained)) [content-end] minmax(var(--space-s), 1fr) @@ -41,7 +39,6 @@ body { min-height: 100vh; } -/* Typography */ h1, h2, h3, h4, h5, h6 { line-height: 1.2; margin-block: 1.5em 0.5em; @@ -56,17 +53,15 @@ p, ul, ol, dl { margin-block: 1em; } -/* Links */ a { color: var(--color-accent); text-underline-offset: 0.2em; - + &:hover { text-decoration: none; } } -/* Layout */ header, main, footer { grid-column: content; } @@ -78,29 +73,29 @@ header { flex-wrap: wrap; gap: var(--space-s); align-items: center; - + & nav { display: flex; gap: var(--space-s); flex-wrap: wrap; - + &:first-child { flex: 1; } - + & a { text-decoration: none; - + &:hover { text-decoration: underline; } - + &[aria-current] { font-weight: bold; } } } - + & .language-switcher { margin-inline-start: auto; } @@ -115,26 +110,24 @@ footer { padding-block: var(--space-s); font-size: 0.875rem; color: var(--color-muted); - + & nav { display: flex; gap: var(--space-s); margin-block-end: var(--space-xs); } - + & p { margin: 0; } } -/* Images */ img { max-width: 100%; height: auto; display: block; } -/* Code */ code { background: var(--color-accent-light); padding: 0.125em 0.25em; @@ -147,14 +140,13 @@ pre { padding: var(--space-s); border-radius: 0.5em; overflow-x: auto; - + & code { background: none; padding: 0; } } -/* Tables */ table { border-collapse: collapse; width: 100%; @@ -171,7 +163,6 @@ th { font-weight: bold; } -/* Blockquote */ blockquote { margin-inline: 0; padding-inline-start: var(--space-s); @@ -179,7 +170,6 @@ blockquote { color: var(--color-muted); } -/* Article metadata */ article { & time { color: var(--color-muted); From ccfca94b57edbcaacb368db3afb595e1c11a5da0 Mon Sep 17 00:00:00 2001 From: Ruben Date: Wed, 14 Jan 2026 14:40:57 +0100 Subject: [PATCH 3/8] 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 --- devel/Containerfile | 14 ++ devel/compose.yaml | 2 +- devel/perf.sh | 323 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 devel/Containerfile create mode 100755 devel/perf.sh diff --git a/devel/Containerfile b/devel/Containerfile new file mode 100644 index 0000000..6a264d3 --- /dev/null +++ b/devel/Containerfile @@ -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 diff --git a/devel/compose.yaml b/devel/compose.yaml index 132c6bb..0103721 100644 --- a/devel/compose.yaml +++ b/devel/compose.yaml @@ -15,7 +15,7 @@ services: # - "4040:80" # command: bash -c "a2enconf custom && a2enmod rewrite && apache2-foreground" default: - image: php:8.4.14-apache + build: ./ container_name: folderweb-default working_dir: /var/www/html/ volumes: diff --git a/devel/perf.sh b/devel/perf.sh new file mode 100755 index 0000000..239dc20 --- /dev/null +++ b/devel/perf.sh @@ -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 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 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' +\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 + $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 From d54cdc7ce1c36901eb1f675a2a644d97cec73686 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 16 Jan 2026 22:03:15 +0100 Subject: [PATCH 4/8] 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 --- app/plugins/global/languages.php | 38 +++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/app/plugins/global/languages.php b/app/plugins/global/languages.php index 8f14978..4425454 100644 --- a/app/plugins/global/languages.php +++ b/app/plugins/global/languages.php @@ -77,15 +77,38 @@ function loadTranslations(string $lang): array { $defaultFile = dirname(__DIR__, 2) . "/default/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)) { - $translations = array_merge($translations, parse_ini_file($customFile) ?: []); + $translations = array_merge($translations, flattenIniSections(parse_ini_file($customFile, true) ?: [])); } 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 { if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $dateString, $m)) { return $dateString; @@ -116,10 +139,13 @@ function filterFilesByLanguage(array $files, string $dir, Context $ctx): array { foreach ($files as $file) { $parts = explode('.', $file['name']); - // Language-specific file (name.lang.ext) - if (count($parts) >= 3 && in_array($parts[count($parts) - 2], $availableLangs)) { - $fileLang = $parts[count($parts) - 2]; - if ($fileLang === $currentLang) { + // Language-specific file (name.lang.ext) - detect by 2-letter code pattern + $potentialLang = $parts[count($parts) - 2] ?? ''; + $isLangFile = count($parts) >= 3 && preg_match('/^[a-z]{2}$/', $potentialLang); + + if ($isLangFile) { + // Only include if it matches current language AND is available + if ($potentialLang === $currentLang && in_array($potentialLang, $availableLangs)) { $filtered[] = $file; $seen[$parts[0]] = true; } From d446ab1896aa7507ed569c7305d8d286328c6ab6 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 16 Jan 2026 22:03:20 +0100 Subject: [PATCH 5/8] 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 --- app/rendering.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/rendering.php b/app/rendering.php index 1ee87e1..7ed8670 100644 --- a/app/rendering.php +++ b/app/rendering.php @@ -20,7 +20,7 @@ function renderContentFile(string $filePath, ?Context $ctx = null): string { if (!class_exists('ParsedownExtra')) { require_once __DIR__ . '/vendor/ParsedownExtra.php'; } - $html = '
' . (new ParsedownExtra())->text(file_get_contents($filePath)) . '
'; + $html = (new ParsedownExtra())->text(file_get_contents($filePath)); if ($langPrefix) { $html = preg_replace( @@ -73,13 +73,14 @@ function renderFile(Context $ctx, string $filePath): void { $ext = pathinfo($realPath, PATHINFO_EXTENSION); 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); $pageMetadata = loadMetadata($pageDir); - getPluginManager()->loadPagePlugins($pageMetadata); + $content = renderContentFile($realPath, $ctx); + $navigation = $ctx->navigation; $homeLabel = $ctx->homeLabel; $pageTitle = $pageMetadata['title'] ?? null; @@ -122,15 +123,16 @@ function renderFile(Context $ctx, string $filePath): 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 = ''; foreach ($files as $file) { $content .= renderContentFile($file, $ctx); } - $pageMetadata = loadMetadata($pageDir); - - getPluginManager()->loadPagePlugins($pageMetadata); - $navigation = $ctx->navigation; $homeLabel = $ctx->homeLabel; $pageTitle = $pageMetadata['title'] ?? null; From 7b5d07a88dfcfccab254328d29920428cbd92c30 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 16 Jan 2026 22:03:25 +0100 Subject: [PATCH 6/8] Add ISO date format for fallback date extraction Convert fallback date to ISO format before plugin processing --- app/router.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/router.php b/app/router.php index d5345b2..ceaa25c 100644 --- a/app/router.php +++ b/app/router.php @@ -133,7 +133,14 @@ switch ($parsedPath['type']) { // Let plugins format date $date = Hooks::apply(Hook::PROCESS_CONTENT, $date, 'date_format'); } 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 From 0901d6324c89efbd4b55a19796f2ab5e7a95f777 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 16 Jan 2026 22:03:31 +0100 Subject: [PATCH 7/8] Update ParsedownExtra to use mb_encode_numericentity for safer HTML encoding --- app/vendor/ParsedownExtra.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/vendor/ParsedownExtra.php b/app/vendor/ParsedownExtra.php index 2adcbaa..95ef854 100644 --- a/app/vendor/ParsedownExtra.php +++ b/app/vendor/ParsedownExtra.php @@ -625,7 +625,7 @@ class ParsedownExtra extends Parsedown $DOMDocument = new DOMDocument; # 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 $DOMDocument->loadHTML($elementMarkup); @@ -683,4 +683,4 @@ class ParsedownExtra extends Parsedown # protected $regexAttribute = '(?:[#.][-\w]+[ ]*)'; -} \ No newline at end of file +} From 7782eefa968b5e6675589ec8cf00c7da78dea815 Mon Sep 17 00:00:00 2001 From: Ruben Date: Sat, 17 Jan 2026 00:03:53 +0100 Subject: [PATCH 8/8] Add trailing slash redirect for list routes --- app/router.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/router.php b/app/router.php index ceaa25c..c4e61dd 100644 --- a/app/router.php +++ b/app/router.php @@ -82,6 +82,12 @@ switch ($parsedPath['type']) { case 'list': $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 $pageContent = null; $contentFiles = findAllContentFiles($dir);