diff --git a/AGENT.md b/AGENT.md
index 53d6dc2..34d2d78 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -1,41 +1,22 @@
-# FolderWeb
-
-Minimal file-based CMS. Folders = URLs. PHP 8.4+, no JS, no frameworks, no build tools.
-
## Philosophy
-
-Decade-scale maintainability. Only essential tech. Readable, simple, future-proof. No volatile dependencies.
-
-## Two Modes of Work
-
-1. **Building on top** (`custom/`): Create sites using the framework. Never modify `app/`. In this mode, `app/` is typically symlinked or submoduled from the framework repo into a separate site repo.
-2. **Framework development** (`app/`): Evolve the core. Preserve all stable contracts (see architecture doc). `custom/` may be symlinked in from a site repo for testing.
+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.
## Core Constraints
+**Minimalism:** Only essential tech (HTML, PHP 8.4+, CSS). No JS, frameworks, build tools, or package managers. Comments only for major sections.
-- **Stack:** HTML5, PHP 8.4+, CSS. Nothing else.
-- **Frontend:** Classless semantic HTML, modern CSS (nesting, `oklch()`, grid, `clamp()`, logical props)
-- **Security:** Path traversal protection, document root restriction, strict MIME types, escape all UGC
+**Frontend:**
+- Classless, semantic HTML5
+- Modern CSS: nesting, `oklch()`, grid, `clamp()`, logical props
+- Responsive via fluid typography + flexible layouts
+
+**Security:**
+- Path validation blocks traversal
+- Files restricted to document root
+- Strict MIME types + no direct user-input execution
## Code Style
+**PHP:** Modern syntax (arrow functions, null coalescing, match). Type hints where practical. Ternary for simple conditionals. Single-purpose functions.
-- **PHP:** Arrow functions, null coalescing, match expressions. Type hints where practical. Single-purpose functions. Comments only for major sections.
-- **CSS:** Variables, native nesting, grid. `clamp()` over `@media`. Relative units.
-- **Templates:** `= htmlspecialchars($var) ?>` for UGC. `= $content ?>` for pre-rendered HTML.
+**CSS:** Variables, native nesting, grid layouts. `clamp()` over `@media`. Relative units > pixels.
-## Knowledge Base
-
-Read these docs on-demand when working on related areas. Do not load all at once.
-
-| Skill | File | Read When |
-|---|---|---|
-| Architecture | `docs/04-development/01-architecture.md` | Understanding project structure, request flow, module dependencies, stable contracts |
-| Content System | `docs/04-development/02-content-system.md` | Working with routing, URL resolution, metadata, content discovery, navigation |
-| Configuration | `docs/04-development/03-configuration.md` | Config loading, the `custom/` override system, static asset routing |
-| Context API | `docs/04-development/04-context-api.md` | The Context class, Templates class, built-in context keys |
-| Hooks & Plugins | `docs/04-development/05-hooks-plugins.md` | Hook system, plugin manager, writing plugins, the language plugin |
-| Templates | `docs/04-development/06-templates.md` | Template hierarchy, resolution, variables, list item schema, partials |
-| Rendering | `docs/04-development/07-rendering.md` | Rendering pipeline, Markdown caching, static file serving, Parsedown |
-| Dev Environment | `docs/04-development/08-dev-environment.md` | Container setup, Apache config, performance profiling, test data generation |
-
-Human-facing docs (tutorials, reference) are in `docs/01-getting-started/`, `docs/02-tutorial/`, `docs/03-reference/`.
+**Templates:** Escape output (`htmlspecialchars()` for UGC). Short echo tags (`= $var ?>`).
diff --git a/app/rendering.php b/app/rendering.php
index 7d61f89..7ed8670 100644
--- a/app/rendering.php
+++ b/app/rendering.php
@@ -64,6 +64,63 @@ function renderTemplate(Context $ctx, string $content, int $statusCode = 200): v
exit;
}
+function renderFile(Context $ctx, string $filePath): void {
+ $realPath = realpath($filePath);
+ if (!$realPath || !str_starts_with($realPath, $ctx->contentDir) || !is_readable($realPath)) {
+ renderTemplate($ctx, "403 Forbidden Access denied.
", 403);
+ }
+
+ $ext = pathinfo($realPath, PATHINFO_EXTENSION);
+
+ if (in_array($ext, CONTENT_EXTENSIONS)) {
+ // 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;
+ $metaDescription = extractMetaDescription($pageDir, $pageMetadata);
+
+ $pageCss = findPageCss($pageDir, $ctx->contentDir);
+ $pageCssUrl = $pageCss['url'] ?? null;
+ $pageCssHash = $pageCss['hash'] ?? null;
+
+ $coverImage = findCoverImage($pageDir);
+ $socialImageUrl = null;
+ if ($coverImage) {
+ $relativePath = str_replace($ctx->contentDir, '', $pageDir);
+ $relativePath = trim($relativePath, '/');
+ $socialImageUrl = '/' . ($relativePath ? $relativePath . '/' : '') . $coverImage;
+ }
+
+ $templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [
+ 'content' => $content,
+ 'navigation' => $navigation,
+ 'homeLabel' => $homeLabel,
+ 'pageTitle' => $pageTitle,
+ 'metaDescription' => $metaDescription,
+ 'pageCssUrl' => $pageCssUrl,
+ 'pageCssHash' => $pageCssHash,
+ 'socialImageUrl' => $socialImageUrl
+ ], $ctx);
+
+ extract($templateVars);
+
+ ob_start();
+ require $ctx->templates->page;
+ $wrappedContent = ob_get_clean();
+
+ include $ctx->templates->base;
+ exit;
+ }
+
+ renderTemplate($ctx, "404 - Not Found The requested file could not be found.
", 404);
+}
function renderMultipleFiles(Context $ctx, array $files, string $pageDir): void {
// Load metadata and page plugins BEFORE rendering content files
diff --git a/app/router.php b/app/router.php
index 64e706a..c4e61dd 100644
--- a/app/router.php
+++ b/app/router.php
@@ -110,6 +110,9 @@ switch ($parsedPath['type']) {
$listTemplate = $ctx->templates->list;
if (isset($metadata['page_template']) && !empty($metadata['page_template'])) {
$templateName = $metadata['page_template'];
+ if (!str_ends_with($templateName, '.php')) {
+ $templateName .= '';
+ }
$customTemplate = dirname(__DIR__) . "/custom/templates/$templateName.php";
$defaultTemplate = __DIR__ . "/default/templates/$templateName.php";
@@ -183,8 +186,6 @@ switch ($parsedPath['type']) {
// Check for page-specific CSS
$pageCss = findPageCss($dir, $ctx->contentDir);
- $pageCssUrl = $pageCss['url'] ?? null;
- $pageCssHash = $pageCss['hash'] ?? null;
// Let plugins add template variables
$templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [
@@ -192,8 +193,7 @@ switch ($parsedPath['type']) {
'homeLabel' => $homeLabel,
'pageTitle' => $pageTitle,
'metaDescription' => $metaDescription,
- 'pageCssUrl' => $pageCssUrl,
- 'pageCssHash' => $pageCssHash,
+ 'pageCss' => $pageCss,
'items' => $items,
'pageContent' => $pageContent
], $ctx);
diff --git a/docs/04-development/01-architecture.md b/docs/04-development/01-architecture.md
deleted file mode 100644
index e7f6d27..0000000
--- a/docs/04-development/01-architecture.md
+++ /dev/null
@@ -1,137 +0,0 @@
-# Architecture
-
-## Directory Layout
-
-```
-app/ # Framework core — stable API surface
- router.php # Entry point: all requests route here
- constants.php # CONTENT_EXTENSIONS, COVER_IMAGE_EXTENSIONS
- hooks.php # Hook enum + Hooks class
- context.php # Context + Templates classes
- config.php # createContext(): config merge + bootstrap
- helpers.php # Utility functions (template resolution, extraction)
- content.php # Content discovery, slug resolution, navigation
- rendering.php # Markdown/HTML/PHP rendering, template wrapping
- cache.php # Markdown render cache (/tmp/folderweb_cache)
- plugins.php # PluginManager class
- static.php # /app/* asset serving with traversal protection
- vendor/ # Parsedown + ParsedownExtra (Markdown→HTML)
- plugins/global/ # Built-in plugins (languages.php)
- default/ # Demo/fallback theme (NOT for production use)
- config.ini # Default configuration
- templates/ # base.php, page.php, list.php, list-compact.php, list-grid.php
- styles/ # Default stylesheet
- languages/ # en.ini, no.ini
- content/ # Demo content shown when custom/ has no content
-custom/ # User layer — all site-specific work goes here
- config.ini # Config overrides (merged over app/default/config.ini)
- templates/ # Override any template by matching filename
- styles/ # Stylesheets served via /app/styles/*
- fonts/ # Fonts served via /app/fonts/*
- assets/ # Static files served at document root (favicon, robots.txt)
- languages/ # Translation overrides/additions (*.ini)
- plugins/global/ # Custom global plugins
- plugins/page/ # Custom page plugins (reserved, not yet active)
-content/ # Website content (= document root in production)
-devel/ # Dev environment (Containerfile, compose, Apache, perf tools)
-docs/ # Documentation (01-03 human-facing, 04 machine-facing)
-```
-
-## Stable Contracts
-
-**`app/` is the framework.** When developing `custom/` sites, never modify `app/`. When developing the framework itself, preserve these contracts:
-
-| Contract | Guaranteed Behavior |
-|---|---|
-| Override chain | `custom/*` always takes priority over `app/default/*` for templates, styles, languages, config |
-| Template names | `base.php`, `page.php`, `list.php` are the three core template names |
-| Hook enum | `Hook::CONTEXT_READY`, `Hook::PROCESS_CONTENT`, `Hook::TEMPLATE_VARS` — signatures documented in `05-hooks-plugins.md` |
-| Context API | `$ctx->set()`, `$ctx->get()`, `$ctx->has()` — stable key/value store |
-| Content extensions | `md`, `html`, `php` — defined in `CONTENT_EXTENSIONS` |
-| Config format | INI with sections, merged via `array_replace_recursive` |
-| Metadata format | INI file named `metadata.ini` in content directories |
-| URL structure | Folder path = URL path, with slug overrides via metadata |
-| Plugin locations | `app/plugins/{scope}/` and `custom/plugins/{scope}/` |
-| Asset routes | `/app/styles/*` → `custom/styles/`, `/app/fonts/*` → `custom/fonts/`, `/app/default-styles/*` → `app/default/styles/` |
-| Trailing slash | Pages and lists enforce trailing slash via 301 redirect |
-
-## Request Flow
-
-```
-Browser request
- │
- ├─ Apache rewrite: all non-/app/ requests → app/router.php
- │
- ▼
-router.php
- │
- ├─ 1. Load modules (constants, hooks, context, helpers, plugins, config, content, rendering)
- ├─ 2. createContext()
- │ ├─ Parse + merge config (default ← custom)
- │ ├─ loadGlobalPlugins() → fires Hook::CONTEXT_READY
- │ ├─ Determine contentDir (custom content or demo fallback)
- │ ├─ Parse REQUEST_URI → requestPath
- │ └─ Resolve template paths (custom fallback to default)
- │
- ├─ 3. Check custom/assets/{path} → serve static file + exit
- ├─ 4. Check content/{path} for static asset (css/img/pdf/font) → serve + exit
- │
- ├─ 5. Empty path? → frontpage: findAllContentFiles + renderMultipleFiles
- │
- └─ 6. parseRequestPath() → {type, path}
- │
- ├─ "page": trailing slash redirect → findAllContentFiles → renderMultipleFiles
- │ (fires Hook::PROCESS_CONTENT for file filtering)
- │ (fires Hook::TEMPLATE_VARS before template render)
- │ Template chain: content → page.php → base.php
- │
- ├─ "list": trailing slash redirect → build items array from subdirectories
- │ ├─ Check hide_list metadata → treat as page if true
- │ ├─ Select list template from metadata page_template
- │ ├─ For each subdir: loadMetadata, extractTitle, extractDateFromFolder, findCoverImage
- │ ├─ Sort items by date (metadata `order` = ascending|descending)
- │ ├─ Fire Hook::TEMPLATE_VARS
- │ └─ Template chain: items → list-*.php → base.php
- │
- └─ "not_found": 404 response
-```
-
-## Module Dependency Order
-
-Loaded sequentially in `router.php`:
-
-```
-constants.php → hooks.php → context.php → helpers.php → plugins.php → config.php → content.php → rendering.php
-```
-
-`config.php` calls `createContext()` which triggers plugin loading, so `hooks.php` and `plugins.php` must be loaded before it.
-
-## Page vs List Detection
-
-A resolved directory becomes a **list** if it contains subdirectories, otherwise a **page**. Override with `hide_list = true` in metadata to force page rendering on directories with children.
-
-## Deployment Models
-
-### Framework development (this repo)
-
-Both `app/` and `custom/` live in the same repository. `custom/` holds demo/test overrides.
-
-### Site development (separate repo)
-
-The site is its own git repository containing `custom/`, content, and deployment config. `app/` is included as a symlink, git submodule, or copied directory pointing to a specific framework version. The site repo never modifies `app/`.
-
-Typical site repo layout:
-
-```
-my-site/ # Site git repo
- app/ → ../folderweb/app # Symlink to framework (or submodule, or copy)
- custom/ # Site-specific templates, styles, plugins, config
- content/ # Website content (often the document root)
- devel/ # Site's own dev environment config (optional)
-```
-
-Either direction works: `app/` symlinked into a site repo, or `custom/` symlinked into the framework repo during development.
-
-## Demo Fallback
-
-When `content/` (document root) has no files, `app/default/content/` is used automatically. This is **demo mode only** — production sites always provide their own content via the document root.
diff --git a/docs/04-development/01-plugin-system.md b/docs/04-development/01-plugin-system.md
new file mode 100644
index 0000000..b5020c8
--- /dev/null
+++ b/docs/04-development/01-plugin-system.md
@@ -0,0 +1,648 @@
+# Plugin System
+
+FolderWeb uses a minimal hook-based plugin system for extensibility. Plugins let you modify content, add functionality, and inject custom variables into templates—all without touching the framework code.
+
+## How Plugins Work
+
+Plugins are PHP files that register callbacks with one or more **hooks**:
+
+1. **`Hook::CONTEXT_READY`** — After context is created, before routing
+2. **`Hook::PROCESS_CONTENT`** — When loading/processing content
+3. **`Hook::TEMPLATE_VARS`** — Before rendering templates
+
+Each hook receives data, allows modification, and returns the modified data.
+
+## Plugin Locations
+
+```
+app/plugins/
+├── global/ # Built-in global plugins (don't modify)
+│ └── languages.php
+└── page/ # Built-in page plugins (empty by default)
+
+custom/plugins/
+├── global/ # Your global plugins
+│ ├── analytics.php
+│ └── reading-time.php
+└── page/ # Your page plugins (not yet used)
+```
+
+**Global plugins:** Loaded on every request
+**Page plugins:** Reserved for future use
+
+## Enabling Plugins
+
+List enabled plugins in `custom/config.ini`:
+
+```ini
+[plugins]
+enabled = "languages,analytics,reading-time"
+```
+
+Plugin names correspond to filenames without `.php`:
+- `languages` → `languages.php`
+- `analytics` → `analytics.php`
+- `reading-time` → `reading-time.php`
+
+FolderWeb loads plugins from:
+1. `app/plugins/global/` (built-in)
+2. `custom/plugins/global/` (yours)
+
+## The Three Hooks
+
+### `Hook::CONTEXT_READY`
+
+Called after the context object is created, before routing begins.
+
+**Use for:**
+- Setting global context values
+- Processing configuration
+- Adding cross-cutting concerns
+
+**Signature:**
+
+```php
+Hooks::add(Hook::CONTEXT_READY, function(Context $ctx, array $config) {
+ // Modify context
+ $ctx->set('key', 'value');
+
+ // Must return context
+ return $ctx;
+});
+```
+
+**Parameters:**
+- `$ctx` — Context object (see [Context API](#context-api))
+- `$config` — Merged configuration array from `config.ini`
+
+**Must return:** Modified `$ctx`
+
+### `Hook::PROCESS_CONTENT`
+
+Called when loading or processing content (files, metadata, dates).
+
+**Use for:**
+- Filtering content files
+- Transforming metadata
+- Custom content processing
+
+**Signature:**
+
+```php
+Hooks::add(Hook::PROCESS_CONTENT, function(mixed $data, string $dirOrType, string $extraContext = '') {
+ // Process data based on type
+ if ($extraContext === 'metadata') {
+ // Modify metadata array
+ $data['custom_field'] = 'value';
+ }
+
+ // Must return data
+ return $data;
+});
+```
+
+**Parameters:**
+- `$data` — The data being processed (type varies)
+- `$dirOrType` — Directory path or processing type
+- `$extraContext` — Additional context (e.g., `"metadata"`, `"date_format"`)
+
+**Must return:** Modified `$data`
+
+**Common `$extraContext` values:**
+- `"metadata"` — Processing metadata array
+- `"date_format"` — Formatting a date string
+
+### `Hook::TEMPLATE_VARS`
+
+Called before rendering templates, allowing you to add variables.
+
+**Use for:**
+- Adding custom template variables
+- Computing values for display
+- Injecting data into templates
+
+**Signature:**
+
+```php
+Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
+ // Add custom variables
+ $vars['siteName'] = 'My Website';
+ $vars['currentYear'] = date('Y');
+
+ // Must return vars
+ return $vars;
+});
+```
+
+**Parameters:**
+- `$vars` — Array of template variables
+- `$ctx` — Context object
+
+**Must return:** Modified `$vars` array
+
+## Context API
+
+The `Context` object stores global state. Access it in hooks:
+
+```php
+// Set a value
+$ctx->set('key', 'value');
+
+// Get a value
+$value = $ctx->get('key');
+
+// Get with default
+$value = $ctx->get('key', 'default');
+
+// Check if exists
+if ($ctx->has('key')) {
+ // ...
+}
+```
+
+**Built-in context values:**
+
+| Key | Type | Description |
+|-----|------|-------------|
+| `requestPath` | String | URL path (e.g., `"blog/my-post"`) |
+| `contentDir` | String | Filesystem path to content |
+| `currentLang` | String | Current language (from languages plugin) |
+| `defaultLang` | String | Default language |
+| `translations` | Array | Translated strings |
+| `metadata` | Array | Current page metadata |
+
+## Creating Your First Plugin
+
+Let's create a plugin that adds a reading time estimate to posts.
+
+### Step 1: Create the Plugin File
+
+**custom/plugins/global/reading-time.php:**
+
+```php
+
+
+
+
+ = $content ?>
+
+
+```
+
+Done! Every page now shows reading time.
+
+## Plugin Examples
+
+### Analytics Plugin
+
+Add Google Analytics tracking ID to all pages.
+
+**custom/plugins/global/analytics.php:**
+
+```php
+
+
+
+
+
+
+
+
+```
+
+### Table of Contents Plugin
+
+Generate a table of contents from headings.
+
+**custom/plugins/global/toc.php:**
+
+```php
+(.*?)<\/h\1>/i', $content, $matches)) {
+ foreach ($matches[0] as $i => $match) {
+ $level = (int)$matches[1][$i];
+ $text = strip_tags($matches[2][$i]);
+ $id = slugify($text);
+
+ // Add ID to heading
+ $newHeading = str_replace('', '', $match);
+ $content = str_replace($match, $newHeading, $content);
+
+ $toc[] = [
+ 'level' => $level,
+ 'text' => $text,
+ 'id' => $id,
+ ];
+ }
+ }
+
+ $vars['content'] = $content;
+ $vars['tableOfContents'] = $toc;
+
+ return $vars;
+});
+
+function slugify(string $text): string {
+ $text = strtolower($text);
+ $text = preg_replace('/[^a-z0-9]+/', '-', $text);
+ return trim($text, '-');
+}
+```
+
+**Use in template:**
+
+```php
+
+
+ Table of Contents
+
+
+
+
+
+ = $content ?>
+
+```
+
+### Author Bio Plugin
+
+Add author information from metadata.
+
+**custom/plugins/global/author-bio.php:**
+
+```php
+get('metadata', []);
+
+ // Load author data if specified
+ if (isset($metadata['author'])) {
+ $authorSlug = slugify($metadata['author']);
+ $authorFile = dirname(__DIR__, 2) . "/content/authors/$authorSlug.ini";
+
+ if (file_exists($authorFile)) {
+ $authorData = parse_ini_file($authorFile);
+ $vars['authorBio'] = $authorData;
+ }
+ }
+
+ return $vars;
+});
+
+function slugify(string $text): string {
+ return strtolower(preg_replace('/[^a-z0-9]+/', '-', $text));
+}
+```
+
+**content/authors/jane-doe.ini:**
+
+```ini
+name = "Jane Doe"
+bio = "Web developer and writer"
+email = "jane@example.com"
+twitter = "@janedoe"
+website = "https://janedoe.com"
+```
+
+**Use in template:**
+
+```php
+
+
+
+```
+
+### Related Posts Plugin
+
+Show related posts based on tags.
+
+**custom/plugins/global/related-posts.php:**
+
+```php
+get('metadata', []);
+
+ // Only for pages with tags
+ if (!isset($metadata['tags'])) {
+ return $vars;
+ }
+
+ $currentPath = $ctx->get('currentPath', '');
+ $currentTags = array_map('trim', explode(',', $metadata['tags']));
+
+ // Find other posts with similar tags
+ $contentDir = $ctx->contentDir;
+ $relatedPosts = findRelatedPosts($contentDir, $currentPath, $currentTags);
+
+ if (!empty($relatedPosts)) {
+ $vars['relatedPosts'] = $relatedPosts;
+ }
+
+ return $vars;
+});
+
+function findRelatedPosts(string $contentDir, string $currentPath, array $currentTags): array {
+ $posts = [];
+
+ // Recursively scan content directory
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($contentDir, RecursiveDirectoryIterator::SKIP_DOTS)
+ );
+
+ foreach ($iterator as $file) {
+ if ($file->getFilename() === 'metadata.ini') {
+ $dir = dirname($file->getPathname());
+
+ // Skip current page
+ if ($dir === $currentPath) continue;
+
+ $metadata = parse_ini_file($file->getPathname());
+
+ if (isset($metadata['tags'])) {
+ $tags = array_map('trim', explode(',', $metadata['tags']));
+ $commonTags = array_intersect($currentTags, $tags);
+
+ if (!empty($commonTags)) {
+ $posts[] = [
+ 'title' => $metadata['title'] ?? basename($dir),
+ 'url' => str_replace($contentDir, '', $dir) . '/',
+ 'tags' => $tags,
+ 'relevance' => count($commonTags),
+ ];
+ }
+ }
+ }
+ }
+
+ // Sort by relevance
+ usort($posts, fn($a, $b) => $b['relevance'] <=> $a['relevance']);
+
+ // Return top 3
+ return array_slice($posts, 0, 3);
+}
+```
+
+**Use in template:**
+
+```php
+
+
+
+```
+
+## Best Practices
+
+### 1. Always Return Modified Data
+
+```php
+// Good
+Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
+ $vars['custom'] = 'value';
+ return $vars; // Always return
+});
+
+// Bad
+Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
+ $vars['custom'] = 'value';
+ // Missing return - breaks other plugins!
+});
+```
+
+### 2. Use Configuration for Settings
+
+```php
+// Good: configurable
+Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
+ global $config;
+
+ $wordsPerMinute = $config['reading_time']['words_per_minute'] ?? 200;
+ // Use $wordsPerMinute...
+
+ return $vars;
+});
+```
+
+**custom/config.ini:**
+
+```ini
+[reading_time]
+words_per_minute = 250
+```
+
+### 3. Check Variable Existence
+
+```php
+// Good: defensive
+Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
+ if (isset($vars['content'])) {
+ // Process content
+ }
+ return $vars;
+});
+
+// Bad: assumes content exists
+Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
+ $wordCount = str_word_count($vars['content']); // May error
+ return $vars;
+});
+```
+
+### 4. Namespace Helper Functions
+
+```php
+// Good: prefixed function name
+function readingTime_calculate(string $content): int {
+ // ...
+}
+
+// Bad: generic name (may conflict)
+function calculate(string $content): int {
+ // ...
+}
+```
+
+### 5. Use Type Hints
+
+```php
+// Good: type hints for clarity
+Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx): array {
+ $vars['custom'] = 'value';
+ return $vars;
+});
+```
+
+## Debugging Plugins
+
+### Check Plugin Loading
+
+Add debug output to verify your plugin loads:
+
+```php
+
+```
+
+**Remove before deploying to production.**
+
+## Limitations
+
+- **No inter-plugin communication:** Plugins can't directly call each other
+- **Single execution order:** Hooks execute in registration order (no priority system)
+- **Global scope:** Be careful with global variables and function names
+- **No automatic loading:** Plugins must be listed in `config.ini`
+
+## What's Next?
+
+- **[Hook Reference](#)** — Detailed documentation of all hooks
+- **[Example Plugins](#)** — More real-world plugin examples
+- **[Contributing](#)** — Share your plugins with the community
diff --git a/docs/04-development/02-content-system.md b/docs/04-development/02-content-system.md
deleted file mode 100644
index 1163dc6..0000000
--- a/docs/04-development/02-content-system.md
+++ /dev/null
@@ -1,126 +0,0 @@
-# Content System
-
-## Content Discovery
-
-**`findAllContentFiles(string $dir): array`** — Returns sorted file paths from `$dir`.
-
-- Scans for files with extensions in `CONTENT_EXTENSIONS` (`md`, `html`, `php`)
-- Skips `index.php` (reserved for server entry points)
-- Fires `Hook::PROCESS_CONTENT($files, $dir)` — plugins filter files (e.g., language variants)
-- Sorts by filename via `strnatcmp` (natural sort: `00-hero`, `01-intro`, `02-body`)
-- Returns flat array of absolute paths
-
-**File ordering convention:** Prefix filenames with `NN-` for explicit ordering. Files without prefix sort after numbered files.
-
-## URL Routing
-
-**Trailing slash enforcement:** All page and list URLs are canonicalized with a trailing slash. Requests without one receive a 301 redirect (e.g., `/about` → `/about/`). The frontpage (`/`) is exempt.
-
-**Frontpage handling:** Empty request path is handled before `parseRequestPath()` — it renders the content root directly via `renderMultipleFiles()`. The `"frontpage"` type from `parseRequestPath()` is never reached in the router's switch statement.
-
-**`parseRequestPath(Context $ctx): array`** — Returns `['type' => string, 'path' => string]`.
-
-Types:
-- `"frontpage"` — empty request path (handled before this function is called)
-- `"page"` — resolved directory with no subdirectories
-- `"list"` — resolved directory with subdirectories
-- `"not_found"` — slug resolution failed
-
-**`resolveSlugToFolder(string $parentDir, string $slug): ?string`** — Matches URL slug to directory name.
-
-Resolution order:
-1. Exact folder name match (`$slug === $item`)
-2. Metadata slug match (`metadata.ini` `slug` field)
-
-Each URL segment is resolved independently, walking the directory tree. This enables language-specific slugs (e.g., `/no/om/` → `content/about/` via `[no] slug = "om"`).
-
-## Metadata
-
-**`loadMetadata(string $dirPath): ?array`** — Parses `metadata.ini` if present.
-
-Returns flat key-value array with a special `_raw` key containing the full parsed INI structure (including sections). The `_raw` key is an internal contract — the language plugin reads `$data['_raw'][$lang]` to merge language-specific overrides. Plugins processing metadata **must preserve `_raw`** if present. Fires `Hook::PROCESS_CONTENT($metadata, $dirPath, 'metadata')`.
-
-### Core Fields
-
-| Field | Type | Default | Purpose |
-|---|---|---|---|
-| `title` | string | First `# heading` or folder name | Page title, list item title, `` tag |
-| `summary` | string | — | List item description, fallback meta description |
-| `date` | string (YYYY-MM-DD) | Folder date prefix or file mtime | Sorting, display |
-| `search_description` | string | Falls back to `summary` | ` ` |
-| `slug` | string | Folder name | URL override |
-| `menu` | bool/int | 0 | Include in navigation |
-| `menu_order` | int | 999 | Navigation sort order (ascending) |
-| `order` | string | `"descending"` | List sort direction (`ascending`\|`descending`) |
-| `redirect` | string | — | External URL (list items can redirect) |
-| `plugins` | string | — | Comma-separated page-level plugin names |
-
-### Settings Section
-
-```ini
-[settings]
-page_template = "list-grid" # List template override (without .php)
-show_date = true # Show date in list items (default: true)
-hide_list = false # Force page rendering even with subdirectories
-```
-
-### Language Sections
-
-```ini
-title = "About"
-slug = "about"
-
-[no]
-title = "Om oss"
-slug = "om"
-```
-
-Supported language-overridable fields: `title`, `summary`, `search_description`, `slug`.
-
-### Custom Fields
-
-Any key not listed above is passed through to templates/plugins unchanged. Add whatever fields your templates need.
-
-## Date Extraction
-
-**`extractDateFromFolder(string $folderName): ?string`** — Extracts date from `YYYY-MM-DD-*` prefix.
-
-If no date prefix exists and no `date` metadata is set, falls back to file modification time (`filemtime`). All dates pass through `Hook::PROCESS_CONTENT($date, 'date_format')` for plugin formatting.
-
-**Note:** Date extraction uses regex only — `2025-13-45-slug` would extract `2025-13-45` without validation. Invalid dates pass through to templates as-is.
-
-**Sorting with null dates:** Items without any date are sorted as empty strings via `strcmp`. Their relative order among other dateless items is undefined.
-
-## Navigation
-
-**`buildNavigation(Context $ctx): array`** — Scans top-level content directories.
-
-Returns items with `menu = 1` metadata, sorted by `menu_order`. Each item: `['title' => string, 'url' => string, 'order' => int]`. URLs include language prefix if applicable.
-
-## Cover Images
-
-**`findCoverImage(string $dirPath): ?string`** — Finds `cover.*` file.
-
-Checks extensions in `COVER_IMAGE_EXTENSIONS` order: `jpg`, `jpeg`, `png`, `webp`, `gif`. Returns filename (not full path) or null.
-
-## PDF Discovery
-
-**`findPdfFile(string $dirPath): ?string`** — Returns basename of first `*.pdf` found, or null.
-
-## Page-Specific CSS
-
-**`findPageCss(string $dirPath, string $contentDir): ?array`** — Checks for `styles.css` in content directory.
-
-Returns `['url' => string, 'hash' => string]` or null. Hash is MD5 of file content for cache busting.
-
-## Meta Description Extraction
-
-**`extractMetaDescription(string $dirPath, ?array $metadata): ?string`**
-
-Priority: `search_description` → `summary` → first paragraph from content files (>20 chars).
-
-## Title Extraction
-
-**`extractTitle(string $filePath): ?string`** — Reads first content file in directory.
-
-Markdown: first `# heading`. HTML/PHP: first `` tag. Returns null if no heading found.
diff --git a/docs/04-development/02-creating-templates.md b/docs/04-development/02-creating-templates.md
new file mode 100644
index 0000000..abd49b6
--- /dev/null
+++ b/docs/04-development/02-creating-templates.md
@@ -0,0 +1,719 @@
+# Creating Custom Templates
+
+Templates control the HTML structure and presentation of your content. This guide covers advanced template creation, from simple page layouts to complex list views.
+
+## Template Hierarchy
+
+FolderWeb uses a three-level template system:
+
+1. **Base template** (`base.php`) — The HTML scaffold wrapping everything
+2. **Content template** — Either `page.php` or a list template
+3. **Partials** (optional) — Reusable components you create
+
+```
+base.php
+└── page.php or list.php
+ └── Rendered content
+```
+
+## Template Resolution
+
+When rendering a page, FolderWeb looks for templates in this order:
+
+**For page views:**
+1. `custom/templates/page.php`
+2. `app/default/templates/page.php` (fallback)
+
+**For list views:**
+1. `custom/templates/{page_template}.php` (e.g., `list-grid.php`)
+2. `custom/templates/list.php`
+3. `app/default/templates/{page_template}.php`
+4. `app/default/templates/list.php` (fallback)
+
+**For base:**
+1. `custom/templates/base.php`
+2. `app/default/templates/base.php` (fallback)
+
+## Creating a Custom Base Template
+
+The base template defines the HTML structure for every page.
+
+### Step 1: Copy the Default
+
+```bash
+cp app/default/templates/base.php custom/templates/base.php
+```
+
+### Step 2: Customize
+
+**custom/templates/base.php:**
+
+```php
+
+
+
+
+
+ = htmlspecialchars($pageTitle ?? 'My Site') ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to main content
+
+
+
+
+
+ = $content ?>
+
+
+
+
+
+
+```
+
+### Key Features
+
+- **Skip link** for accessibility
+- **Container divs** for layout control
+- **Semantic HTML** (header, nav, main, footer)
+- **ARIA labels** for screen readers
+- **Open Graph tags** for social media
+- **Performance metrics** in footer
+
+## Creating Custom Page Templates
+
+Page templates wrap single-page content.
+
+### Blog Post Template
+
+**custom/templates/page.php:**
+
+```php
+
+
+
+
+ = $content ?>
+
+
+
+
+
+
+```
+
+### Portfolio Item Template
+
+**custom/templates/page-portfolio.php:**
+
+```php
+
+
+
+
+
+
+
+
+
+
+ = $content ?>
+
+
+
+
+
+
+```
+
+**To use:** Set in metadata:
+
+```ini
+[settings]
+page_template = "page-portfolio"
+```
+
+Wait, that won't work for page templates—only list templates use `page_template`. For page templates, you'd need to select via a plugin or use different template files per directory. Let's stick with one `page.php` that adapts based on metadata.
+
+## Creating Custom List Templates
+
+List templates display collections of items.
+
+### Card Grid Layout
+
+**custom/templates/list-cards.php:**
+
+```php
+
+
+ = $pageContent ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = $item['formatted_date'] ?? $item['date'] ?>
+
+
+
+
+
+ = htmlspecialchars($item['summary']) ?>
+
+
+
+
+ Read more →
+
+
+
+
+
+```
+
+**Corresponding CSS:**
+
+```css
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
+ gap: 2rem;
+ margin-top: 2rem;
+}
+
+.card {
+ border-radius: 0.5rem;
+ overflow: hidden;
+ box-shadow: 0 2px 8px oklch(0% 0 0 / 0.1);
+ transition: transform 0.2s, box-shadow 0.2s;
+
+ &:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 4px 16px oklch(0% 0 0 / 0.15);
+ }
+}
+
+.card-image img {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ object-fit: cover;
+}
+
+.card-content {
+ padding: 1.5rem;
+}
+
+.card-title {
+ margin: 0 0 0.5rem;
+ font-size: 1.25rem;
+
+ & a {
+ color: inherit;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+.card-date {
+ display: block;
+ font-size: 0.875rem;
+ color: var(--color-text-muted);
+ margin-bottom: 0.75rem;
+}
+
+.card-summary {
+ margin: 0 0 1rem;
+ line-height: 1.6;
+}
+
+.card-link {
+ font-weight: 500;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+```
+
+### Timeline Layout
+
+**custom/templates/list-timeline.php:**
+
+```php
+= $pageContent ?>
+
+
+
+
+
= $year ?>
+
+
+
+
+
+ = $item['formatted_date'] ?? ($item['date'] ?? '') ?>
+
+
+
+
+
+
+
= htmlspecialchars($item['summary']) ?>
+
+
+
+
+
+```
+
+**CSS:**
+
+```css
+.timeline {
+ position: relative;
+ max-width: 800px;
+ margin: 2rem 0;
+ padding-left: 2rem;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background: var(--color-border);
+ }
+}
+
+.timeline-year {
+ margin: 2rem 0 1rem;
+
+ & h2 {
+ font-size: 1.5rem;
+ color: var(--color-accent);
+ }
+}
+
+.timeline-item {
+ position: relative;
+ margin-bottom: 2rem;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: -2.5rem;
+ top: 0.5rem;
+ width: 0.75rem;
+ height: 0.75rem;
+ border-radius: 50%;
+ background: var(--color-accent);
+ border: 2px solid var(--color-bg);
+ }
+}
+
+.timeline-date {
+ display: block;
+ font-size: 0.875rem;
+ color: var(--color-text-muted);
+ margin-bottom: 0.25rem;
+}
+
+.timeline-content {
+ & h3 {
+ margin: 0 0 0.5rem;
+ font-size: 1.125rem;
+ }
+
+ & p {
+ margin: 0;
+ line-height: 1.6;
+ }
+}
+```
+
+### Magazine Layout
+
+**custom/templates/list-magazine.php:**
+
+```php
+= $pageContent ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = htmlspecialchars($featured['summary']) ?>
+
+
+
+
+ Read article →
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = $item['formatted_date'] ?? $item['date'] ?>
+
+
+
+
+
+
+
+
+```
+
+## Using Partials (Template Includes)
+
+Break complex templates into reusable components.
+
+### Creating a Partial
+
+**custom/templates/partials/post-card.php:**
+
+```php
+
+
+
+
+
+
+
+
+
+
+ = htmlspecialchars($post['summary']) ?>
+
+
+```
+
+### Using a Partial
+
+**custom/templates/list.php:**
+
+```php
+= $pageContent ?>
+
+
+
+
+
+
+```
+
+**Note:** Set `$post` before including, as the partial expects it.
+
+## Conditional Templates
+
+Use metadata to vary presentation.
+
+```php
+
+
+ = $content ?>
+
+
+
+
+ = $content ?>
+
+
+
+```
+
+**Set in metadata:**
+
+```ini
+[settings]
+layout = "wide"
+```
+
+## Template Best Practices
+
+### 1. Always Escape Output
+
+```php
+
+= htmlspecialchars($title) ?>
+
+
+= $title ?>
+```
+
+### 2. Check Before Using
+
+```php
+
+
+ By = htmlspecialchars($metadata['author']) ?>
+
+
+
+By = htmlspecialchars($metadata['author']) ?>
+```
+
+### 3. Use Semantic HTML
+
+```php
+
+
+
+ Content
+
+
+
+
+
+```
+
+### 4. Add ARIA Labels
+
+```php
+
+
+
+
+
+
+
+```
+
+### 5. Keep Logic Minimal
+
+```php
+
+
+ = $item['formatted_date'] ?>
+
+
+
+
+ strtotime($item['date']) > strtotime('-30 days')
+);
+usort($recentPosts, fn($a, $b) => strcmp($b['date'], $a['date']));
+?>
+```
+
+## What's Next?
+
+- **[Template Variables Reference](#)** — See all available variables
+- **[Plugin System](#)** — Add custom variables to templates
+- **[Styling Guide](#)** — Style your custom templates
diff --git a/docs/04-development/03-configuration.md b/docs/04-development/03-configuration.md
deleted file mode 100644
index 3500297..0000000
--- a/docs/04-development/03-configuration.md
+++ /dev/null
@@ -1,114 +0,0 @@
-# Configuration
-
-## Config Loading
-
-**`createContext()`** in `app/config.php` handles the full bootstrap:
-
-1. Parse `app/default/config.ini` (framework defaults)
-2. If `custom/config.ini` exists, merge via `array_replace_recursive` (custom wins)
-3. Load global plugins using merged config
-4. Determine content directory
-5. Create `Context` object
-6. Fire `Hook::CONTEXT_READY` — plugins receive the merged `$config` array
-
-## INI Format
-
-Standard PHP `parse_ini_file` with sections enabled:
-
-```ini
-[languages]
-default = "en"
-available = "no,en"
-
-[plugins]
-enabled = "languages"
-
-[custom_section]
-key = "value"
-```
-
-## Built-in Config Keys
-
-| Section | Key | Type | Default | Purpose |
-|---|---|---|---|---|
-| `languages` | `default` | string | `"en"` | Default language (no URL prefix) |
-| `languages` | `available` | string | `"no,en"` | Comma-separated ISO 639-1 codes |
-| `plugins` | `enabled` | string | `"languages"` | Comma-separated plugin names (without `.php`) |
-
-All other sections are custom — define whatever your plugins need.
-
-## The custom/ Override System
-
-This is the primary mechanism for using FolderWeb. The `custom/` directory is the user layer:
-
-```
-custom/
- config.ini # Merged over app/default/config.ini
- templates/ # Override by matching filename (e.g., base.php, page.php, list.php)
- styles/ # Served at /app/styles/*
- fonts/ # Served at /app/fonts/*
- assets/ # Served at document root (favicon.ico, robots.txt, etc.)
- languages/ # Merged over app/default/languages/*.ini
- plugins/
- global/ # Loaded alongside app/plugins/global/
- page/ # Reserved for future use
-```
-
-### Override Resolution Order
-
-| Resource | Lookup | Fallback |
-|---|---|---|
-| Templates | `custom/templates/{name}.php` | `app/default/templates/{name}.php` |
-| Styles | `custom/styles/*` via `/app/styles/*` | `app/default/styles/*` via `/app/default-styles/*` |
-| Languages | `custom/languages/{lang}.ini` merged over | `app/default/languages/{lang}.ini` |
-| Config | `custom/config.ini` merged over | `app/default/config.ini` |
-| Plugins | `custom/plugins/{scope}/{name}.php` | `app/plugins/{scope}/{name}.php` |
-
-For plugins, custom takes priority: if both `custom/plugins/global/foo.php` and `app/plugins/global/foo.php` exist, the custom version loads.
-
-For full template resolution details, see `06-templates.md` "Template Resolution (canonical reference)".
-
-### Static Asset Routing
-
-The router checks these locations before content routing:
-
-1. `custom/assets/{requestPath}` — served with `mime_content_type()`
-2. `{contentDir}/{requestPath}` — served with explicit MIME map for: `css`, `jpg`, `jpeg`, `png`, `gif`, `webp`, `svg`, `pdf`, `woff`, `woff2`, `ttf`, `otf`
-
-`/app/*` requests are handled by `static.php`:
-- `/app/styles/*` → `custom/styles/*`
-- `/app/fonts/*` → `custom/fonts/*`
-- `/app/assets/*` → `custom/assets/*`
-- `/app/default-styles/*` → `app/default/styles/*`
-
-## Accessing Config in Plugins
-
-The merged config array is passed to `Hook::CONTEXT_READY`:
-
-```php
-Hooks::add(Hook::CONTEXT_READY, function(Context $ctx, array $config) {
- $val = $config['my_section']['my_key'] ?? 'default';
- $ctx->set('my_val', $val);
- return $ctx;
-});
-```
-
-Config is **not** directly available in templates. Expose values via `Hook::TEMPLATE_VARS`:
-
-```php
-Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
- global $config;
- $vars['siteTitle'] = $config['site']['title'] ?? 'My Site';
- return $vars;
-});
-```
-
-## Content Directory Resolution
-
-In `createContext()`:
-
-1. `$_SERVER['DOCUMENT_ROOT']` is checked for content (>2 entries in scandir)
-2. If content exists → use document root as `contentDir`
-3. If empty → fall back to `app/default/content/` (demo mode)
-
-Production deployments set Apache's `DocumentRoot` to the content directory. The `app/` and `custom/` directories live outside the document root, accessed via Apache aliases.
diff --git a/docs/04-development/04-context-api.md b/docs/04-development/04-context-api.md
deleted file mode 100644
index 932113e..0000000
--- a/docs/04-development/04-context-api.md
+++ /dev/null
@@ -1,71 +0,0 @@
-# Context API
-
-## Context Class
-
-Defined in `app/context.php`. Stores request state and plugin data.
-
-### Constructor Properties (readonly)
-
-| Property | Type | Access | Description |
-|---|---|---|---|
-| `contentDir` | string | `$ctx->contentDir` | Absolute path to content root |
-| `templates` | Templates | `$ctx->templates` | Resolved template paths |
-| `requestPath` | string | `$ctx->requestPath` | URL path with leading/trailing slashes removed |
-| `hasTrailingSlash` | bool | `$ctx->hasTrailingSlash` | Whether original request had trailing slash |
-
-These use PHP 8.4 `private(set)` — readable but not writable from outside the class.
-
-**Exception:** The language plugin modifies `requestPath` via reflection to strip the language prefix. This is an intentional framework-level operation.
-
-### Computed Properties
-
-| Property | Type | Description |
-|---|---|---|
-| `navigation` | array | `buildNavigation($this)` — lazy-computed on access |
-| `homeLabel` | string | From root `metadata.ini` `slug` field, default `"Home"`. Note: reads `slug`, not `title` — typically set to a short label like "Home" or "Hjem" |
-
-### Plugin Data Store
-
-```php
-$ctx->set(string $key, mixed $value): void
-$ctx->get(string $key, mixed $default = null): mixed
-$ctx->has(string $key): bool
-```
-
-Also supports magic property access: `$ctx->foo = 'bar'` / `$val = $ctx->foo`.
-
-### Built-in Context Keys (set by language plugin)
-
-| Key | Type | Set By | Description |
-|---|---|---|---|
-| `currentLang` | string | languages.php | Active language code (e.g., `"en"`) |
-| `defaultLang` | string | languages.php | Default language from config |
-| `availableLangs` | array | languages.php | All configured language codes |
-| `langPrefix` | string | languages.php | URL prefix: `""` for default, `"/no"` for others |
-| `translations` | array | languages.php | Merged translation strings for current language |
-
-## Templates Class
-
-Defined in `app/context.php`. Readonly value object.
-
-```php
-readonly class Templates {
- public function __construct(
- public string $base, # Path to base.php
- public string $page, # Path to page.php
- public string $list # Path to list.php
- ) {}
-}
-```
-
-Resolved by `resolveTemplate()` — see `06-templates.md` "Template Resolution (canonical reference)" for the full lookup chain.
-
-The list template can be overridden per-directory via `page_template` in metadata — this is resolved at render time in `router.php`, not stored in the Templates object.
-
-## Global Access
-
-Context is stored in `$GLOBALS['ctx']` after creation. Plugins that need context outside hook callbacks access it via:
-
-```php
-$ctx = $GLOBALS['ctx'];
-```
diff --git a/docs/04-development/05-hooks-plugins.md b/docs/04-development/05-hooks-plugins.md
deleted file mode 100644
index d7c668e..0000000
--- a/docs/04-development/05-hooks-plugins.md
+++ /dev/null
@@ -1,140 +0,0 @@
-# Hooks & Plugins
-
-## Hook System
-
-Defined in `app/hooks.php`. Two constructs:
-
-### Hook Enum
-
-```php
-enum Hook: string {
- case CONTEXT_READY = 'context_ready';
- case PROCESS_CONTENT = 'process_content';
- case TEMPLATE_VARS = 'template_vars';
-}
-```
-
-### Hooks Class
-
-```php
-Hooks::add(Hook $hook, callable $callback): void
-Hooks::apply(Hook $hook, mixed $value, mixed ...$args): mixed
-```
-
-`apply` chains all registered callbacks — each receives the return value of the previous. **Callbacks must return the modified value** or the chain breaks.
-
-## The Three Hooks
-
-### Hook::CONTEXT_READY
-
-**When:** After `Context` creation, before routing.
-**Signature:** `function(Context $ctx, array $config): Context`
-**Use:** Set context values, process config, modify request state.
-**Must return:** `$ctx`
-
-### Hook::PROCESS_CONTENT
-
-**When:** During content loading — files, metadata, dates.
-**Signature:** `function(mixed $data, string $dirOrType, string $extraContext = ''): mixed`
-**Must return:** Modified `$data`
-
-Called in these contexts:
-
-| `$data` type | `$dirOrType` | `$extraContext` | Purpose |
-|---|---|---|---|
-| array of `['path','name','ext']` | directory path | `""` | Filter content files (e.g., by language) |
-| array (metadata) | directory path | `"metadata"` | Transform metadata (e.g., merge language sections) |
-| string (date) | `"date_format"` | `""` | Format date string |
-
-**Warning:** The second parameter is overloaded — it is a directory path in the first two cases but a type identifier string in the third. Plugins must distinguish these cases carefully. Use `$extraContext === 'metadata'` to detect metadata processing, and `$dirOrType === 'date_format'` to detect date formatting. In all other cases, `$dirOrType` is a directory path and `$data` is the file list array.
-
-### Hook::TEMPLATE_VARS
-
-**When:** Before rendering any template (page or list).
-**Signature:** `function(array $vars, Context $ctx): array`
-**Must return:** Modified `$vars` array
-
-This is the primary extension point for adding custom template variables.
-
-## Plugin Manager
-
-Defined in `app/plugins.php`. Singleton via `getPluginManager()`.
-
-### Loading
-
-```php
-getPluginManager()->loadGlobalPlugins(array $config) // Called during createContext()
-getPluginManager()->loadPagePlugins(?array $metadata) // Called before rendering
-```
-
-**Global plugins:** Loaded from config `[plugins] enabled = "name1,name2"`.
-**Page plugins:** Loaded from metadata `plugins = "name1,name2"`.
-
-### Resolution Order
-
-For each plugin name, checks:
-1. `custom/plugins/{scope}/{name}.php`
-2. `app/plugins/{scope}/{name}.php`
-
-Custom wins. Each plugin is loaded once (`require_once`).
-
-### Introspection
-
-```php
-getPluginManager()->getLoadedPlugins(): array // Names of all loaded plugins
-getPluginManager()->isLoaded(string $name): bool
-getPluginManager()->getPluginInfo(string $name): ?array // ['path','scope','loaded_at']
-```
-
-## Writing a Plugin
-
-Create `custom/plugins/global/{name}.php`:
-
-```php
-set('my_key', $myVal);
- return $ctx;
-});
-```
-
-### Built-in Plugin: languages.php
-
-The language plugin registers all three hooks:
-
-- **CONTEXT_READY:** Extracts language from URL prefix, sets `currentLang`/`defaultLang`/`langPrefix`/`translations`/`availableLangs` on context
-- **PROCESS_CONTENT:** Filters content files by language variant (`name.lang.ext`), merges language metadata sections, formats dates with translated month names
-- **TEMPLATE_VARS:** Exposes language variables and `$languageUrls` to templates
-
-Language file naming: `name.lang.ext` (e.g., `index.no.md`). A 2-letter code before the extension triggers language filtering.
-
-Translation files: `custom/languages/{lang}.ini` merged over `app/default/languages/{lang}.ini`. Supports INI sections flattened to dot notation (`[section] key = val` → `section.key`).
diff --git a/docs/04-development/06-templates.md b/docs/04-development/06-templates.md
deleted file mode 100644
index 4ffde03..0000000
--- a/docs/04-development/06-templates.md
+++ /dev/null
@@ -1,144 +0,0 @@
-# Templates
-
-## Template Hierarchy
-
-Three levels, rendered inside-out:
-
-```
-Content (Markdown/HTML/PHP rendered to HTML string)
- → page.php or list-*.php (wraps content in semantic markup)
- → base.php (HTML scaffold: doctype, head, header, nav, main, footer)
-```
-
-## Template Resolution (canonical reference)
-
-**`resolveTemplate(string $name): string`** — in `helpers.php`. This is the single resolution function used throughout the framework.
-
-1. Check `custom/templates/{name}.php`
-2. Fall back to `app/default/templates/{name}.php`
-
-The three core templates (`base`, `page`, `list`) are resolved at context creation and stored in `$ctx->templates`. Other docs reference this section for resolution behavior.
-
-### List Template Override
-
-In `metadata.ini`:
-
-```ini
-[settings]
-page_template = "list-grid"
-```
-
-Resolution for list template override (in `router.php`, at render time):
-1. `custom/templates/{page_template}.php`
-2. `app/default/templates/{page_template}.php`
-3. Fall back to `$ctx->templates->list` (the default resolved at context creation)
-
-### Default Templates Provided
-
-| Template | Purpose |
-|---|---|
-| `base.php` | HTML document scaffold |
-| `page.php` | Single page wrapper (`= $content ?> `) |
-| `list.php` | Default list with cover images, dates, summaries |
-| `list-compact.php` | Minimal list variant |
-| `list-grid.php` | Card grid layout |
-
-## Template Variables
-
-Variables are injected via `extract()` — each array key becomes a local variable.
-
-### base.php Variables
-
-| Variable | Type | Always Set | Description |
-|---|---|---|---|
-| `$content` | string (HTML) | yes | Rendered output from page.php or list template |
-| `$pageTitle` | ?string | yes | Page title (null if unset) |
-| `$metaDescription` | ?string | no | SEO description |
-| `$socialImageUrl` | ?string | no | Cover image URL for og:image |
-| `$navigation` | array | yes | Menu items: `['title','url','order']` |
-| `$homeLabel` | string | yes | Home link text |
-| `$pageCssUrl` | ?string | no | Page-specific CSS URL |
-| `$pageCssHash` | ?string | no | CSS cache-bust hash |
-| `$currentLang` | string | plugin | Language code (from languages plugin) |
-| `$langPrefix` | string | plugin | URL language prefix |
-| `$languageUrls` | array | plugin | `[lang => url]` for language switcher |
-| `$translations` | array | plugin | UI strings for current language |
-
-### page.php Variables
-
-All base.php variables plus:
-
-| Variable | Type | Description |
-|---|---|---|
-| `$content` | string (HTML) | Rendered page content |
-| `$metadata` | ?array | Page metadata from `metadata.ini` |
-
-### list-*.php Variables
-
-All base.php variables plus:
-
-| Variable | Type | Description |
-|---|---|---|
-| `$items` | array | List items (see schema below) |
-| `$pageContent` | ?string (HTML) | Rendered content from the list directory itself |
-| `$metadata` | ?array | List directory metadata |
-
-## List Item Schema
-
-Each entry in `$items`:
-
-| Key | Type | Always | Description |
-|---|---|---|---|
-| `title` | string | yes | From metadata, first heading, or folder name |
-| `url` | string | yes | Full URL path with trailing slash and lang prefix |
-| `date` | ?string | no | Formatted date string (plugin-processed) |
-| `summary` | ?string | no | From metadata |
-| `cover` | ?string | no | URL to cover image |
-| `pdf` | ?string | no | URL to first PDF file |
-| `redirect` | ?string | no | External redirect URL |
-
-Items sorted by date — direction controlled by `order` metadata on parent (`descending` default, `ascending` available).
-
-**Asset URLs vs page URLs:** Item `url` uses the translated slug. Asset paths (`cover`, `pdf`) use the actual folder name, ensuring assets resolve regardless of language.
-
-## Adding Custom Template Variables
-
-Via `Hook::TEMPLATE_VARS`:
-
-```php
-Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx): array {
- $vars['myVar'] = 'value';
- return $vars;
-});
-```
-
-Then in any template: `= $myVar ?>`.
-
-## Template Conventions
-
-- **Escape all user-derived output:** `= htmlspecialchars($var) ?>`
-- **Exception:** `$content` is pre-rendered HTML — output raw: `= $content ?>`
-- **Check optional vars:** ``
-- **Use null coalescing for defaults:** `= $var ?? 'fallback' ?>`
-- **Use short echo tags:** `= $expr ?>`
-- **Semantic HTML5:** ``, ``, ``, ``, ``, ``
-- **ARIA labels** on navigation elements
-- **Classless defaults** — the default theme uses semantic HTML without classes where possible
-
-## Partials
-
-Not a framework feature — just PHP includes. Convention:
-
-```
-custom/templates/partials/post-card.php
-```
-
-Include from templates:
-
-```php
-
-
-
-```
-
-Variables from the parent scope are available in the included file.
diff --git a/docs/04-development/07-rendering.md b/docs/04-development/07-rendering.md
deleted file mode 100644
index 2fe68c4..0000000
--- a/docs/04-development/07-rendering.md
+++ /dev/null
@@ -1,104 +0,0 @@
-# Rendering & Caching
-
-## Content Rendering
-
-**`renderContentFile(string $filePath, ?Context $ctx = null): string`**
-
-Renders a single content file to HTML string based on extension:
-
-| Extension | Rendering |
-|---|---|
-| `md` | Parsedown + ParsedownExtra → HTML. Cached. Language prefix injected into internal links. |
-| `html` | Included directly (output buffered) |
-| `php` | Included with `Hook::TEMPLATE_VARS` variables extracted into scope |
-
-PHP content files receive variables from `Hook::TEMPLATE_VARS` (starting with an empty array). This includes plugin-provided variables like `$translations`, `$currentLang`, etc. However, page-level context like `$metadata` and `$pageTitle` is **not** included — those are only available in the wrapping template (page.php/list.php), not in content files.
-
-## Page Rendering
-
-**`renderMultipleFiles(Context $ctx, array $files, string $pageDir): void`**
-
-Used for both frontpage and page views:
-
-1. Load metadata for `$pageDir`
-2. Load page plugins (from metadata `plugins` field)
-3. Render all content files, concatenate HTML
-4. Compute: `$pageTitle`, `$metaDescription`, `$pageCssUrl`/`$pageCssHash`, `$socialImageUrl`
-5. Fire `Hook::TEMPLATE_VARS` with all variables
-6. `extract()` variables → render `page.php` → capture output as `$content`
-7. Render `base.php` with `$content` + base variables
-8. `exit`
-
-## List Rendering
-
-Handled directly in `router.php` (not a separate function):
-
-1. Render directory's own content files as `$pageContent`
-2. Load metadata, check `hide_list`
-3. Select list template from `page_template` metadata
-4. Build `$items` array from subdirectories (metadata + extraction)
-5. Sort items by date
-6. Fire `Hook::TEMPLATE_VARS`
-7. Render list template → capture as `$content`
-8. Pass to `renderTemplate()` which renders `base.php`
-
-**`renderTemplate(Context $ctx, string $content, int $statusCode = 200): void`** — Wraps content in base template. Used for list views and error pages.
-
-## Markdown Caching
-
-Defined in `app/cache.php`. File-based cache in `/tmp/folderweb_cache/`.
-
-**Cache key:** `md5($filePath . $mtime . $langPrefix)`
-
-- Invalidates when file is modified (mtime changes)
-- Invalidates per-language (different link rewriting)
-- No explicit TTL — entries persist until temp directory cleanup
-- **Does not track plugin state** — if a plugin modifies Markdown output (e.g., via PROCESS_CONTENT on files), changing plugin config won't bust the cache. Clear `/tmp/folderweb_cache/` manually after plugin changes that affect rendered Markdown.
-
-```php
-getCachedMarkdown(string $filePath, string $langPrefix = ''): ?string
-setCachedMarkdown(string $filePath, string $html, string $langPrefix = ''): void
-```
-
-## Static File Serving
-
-### Content Assets (router.php)
-
-Before content routing, the router serves static files from content directory with an explicit MIME type allowlist:
-
-`css`, `jpg`, `jpeg`, `png`, `gif`, `webp`, `svg`, `pdf`, `woff`, `woff2`, `ttf`, `otf`
-
-Files not in this list are not served as static assets. Notably, `.js` files are excluded — JavaScript must be placed in `custom/assets/` to be served (at the document root URL), or linked from an external source.
-
-### Custom Assets (router.php)
-
-Files in `custom/assets/` are served at the document root URL. Example: `custom/assets/favicon.ico` → `/favicon.ico`. Uses `mime_content_type()` for MIME detection.
-
-### Framework Assets (static.php)
-
-`/app/*` requests are handled by `static.php` with directory traversal protection (`../` stripped):
-
-| URL Path | Filesystem Path |
-|---|---|
-| `/app/styles/*` | `custom/styles/*` |
-| `/app/fonts/*` | `custom/fonts/*` |
-| `/app/assets/*` | `custom/assets/*` |
-| `/app/default-styles/*` | `app/default/styles/*` |
-
-MIME types resolved from extension map, falling back to `mime_content_type()`.
-
-## CSS Cache Busting
-
-Page-specific CSS gets an MD5 hash appended: `?v={hash}`. Computed by `findPageCss()`. The default theme's CSS is linked directly without hash (uses browser caching).
-
-## Parsedown
-
-Markdown rendering uses `Parsedown` + `ParsedownExtra` from `app/vendor/`. These are the only third-party dependencies. Loaded lazily on first Markdown render.
-
-**Internal link rewriting:** After Markdown→HTML conversion, `href="/..."` links are prefixed with the current language prefix (e.g., `/no`). This ensures Markdown links work correctly in multilingual sites.
-
-## Error Responses
-
-- **403:** Invalid path (outside content directory or unreadable)
-- **404:** Slug resolution failed or unknown route type
-- Both render via `renderTemplate()` with appropriate status code
diff --git a/docs/04-development/08-dev-environment.md b/docs/04-development/08-dev-environment.md
deleted file mode 100644
index e34831f..0000000
--- a/docs/04-development/08-dev-environment.md
+++ /dev/null
@@ -1,130 +0,0 @@
-# Development Environment
-
-## Container Setup
-
-Located in `devel/`. Uses Podman (Docker-compatible).
-
-### Containerfile
-
-Base image: `php:8.4.14-apache` with Xdebug for profiling.
-
-```
-FROM php:8.4.14-apache
-# Xdebug installed with profile mode
-# Trigger profiling: ?XDEBUG_PROFILE=1
-# Output: /tmp/cachegrind.out.*
-```
-
-### compose.yaml
-
-**Default service** (demo mode):
-
-```yaml
-default:
- build: ./
- container_name: folderweb-default
- volumes:
- - ../app:/var/www/app:z
- - ./apache/custom.conf:/etc/apache2/conf-available/custom.conf:z
- - ./apache/default.conf:/etc/apache2/sites-available/000-default.conf:z
- ports:
- - "8080:80"
-```
-
-Mounts `app/` only — uses `app/default/content/` as demo content.
-
-**Custom service** (commented out, template for production-like setup):
-
-```yaml
-custom:
- volumes:
- - ../app:/var/www/app:z
- - ../content:/var/www/html:z # Content as document root
- - ../custom:/var/www/custom:z # Custom overrides
- - ../docs:/var/www/html/docs:z # Docs served as content
- ports:
- - "4040:80"
-```
-
-### Starting
-
-```bash
-cd devel
-podman-compose build
-podman-compose up -d
-# Demo: http://localhost:8080
-```
-
-## Apache Configuration
-
-### default.conf (VirtualHost)
-
-- `DocumentRoot /var/www/html`
-- `DirectoryIndex disabled` (no auto-index)
-- RewriteRule: all non-`/app/` requests → `/app/router.php`
-
-### custom.conf (Aliases)
-
-Maps `/app/*` URLs to filesystem:
-
-```
-Alias /app/default-styles /var/www/app/default/styles
-Alias /app/styles /var/www/custom/styles
-Alias /app/fonts /var/www/custom/fonts
-Alias /app /var/www/app
-```
-
-More specific aliases listed first (Apache processes in order). Enables `mod_rewrite`.
-
-### Production Deployment
-
-The Apache config in `devel/` is a reference. Production may vary but must ensure:
-
-1. **All requests** (except `/app/*`) rewrite to `/app/router.php`
-2. **`/app/` aliased** to the `app/` directory
-3. **`/app/styles/`** aliased to `custom/styles/`
-4. **`/app/fonts/`** aliased to `custom/fonts/`
-5. **Document root** set to the content directory
-6. **`custom/`** accessible to PHP at `../custom/` relative to `app/`
-7. **`DirectoryIndex disabled`** — the router handles all paths
-
-Nginx equivalent: proxy all non-asset requests to `app/router.php`, alias `/app/` paths accordingly.
-
-## Performance Testing
-
-**`devel/perf.sh`** — All-in-one profiling tool using Xdebug + Podman.
-
-### Commands
-
-| Command | Purpose |
-|---|---|
-| `./perf.sh profile /url` | Profile a URL, show top 20 slowest functions |
-| `./perf.sh analyze [file]` | Analyze a cachegrind file (latest if omitted) |
-| `./perf.sh list` | List available cachegrind profiles |
-| `./perf.sh clean` | Remove all cachegrind files |
-| `./perf.sh generate ` | Generate test data: `small` (~100), `medium` (~500), `large` (~1500), `huge` (~5000+) |
-| `./perf.sh generate custom N M D` | Custom: N categories, M posts/cat, D depth levels |
-| `./perf.sh testdata-stats` | Show test data statistics |
-| `./perf.sh testdata-clean` | Remove test data |
-
-### Environment Variables
-
-```bash
-CONTAINER=folderweb-default # Target container name
-PORT=8080 # Target port
-```
-
-### Profiling Workflow
-
-```bash
-cd devel
-./perf.sh generate medium # Create test dataset
-./perf.sh profile / # Profile homepage
-./perf.sh profile /blog-321/ # Profile a specific page
-
-# Export for KCachegrind
-podman cp folderweb-default:/tmp/cachegrind.out.123 ./profile.out
-kcachegrind ./profile.out
-```
-
-Focus on functions consuming >5% of total execution time. The analyzer shows time (ms), memory (KB), call count, and percentage.