diff --git a/AGENT.md b/AGENT.md
index 34d2d78..53d6dc2 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -1,22 +1,41 @@
+# FolderWeb
+
+Minimal file-based CMS. Folders = URLs. PHP 8.4+, no JS, no frameworks, no build tools.
+
## 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.
+
+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.
## Core Constraints
-**Minimalism:** Only essential tech (HTML, PHP 8.4+, CSS). No JS, frameworks, build tools, or package managers. Comments only for major sections.
-**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
+- **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
## Code Style
-**PHP:** Modern syntax (arrow functions, null coalescing, match). Type hints where practical. Ternary for simple conditionals. Single-purpose functions.
-**CSS:** Variables, native nesting, grid layouts. `clamp()` over `@media`. Relative units > pixels.
+- **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.
-**Templates:** Escape output (`htmlspecialchars()` for UGC). Short echo tags (`= $var ?>`).
+## 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/`.
diff --git a/docs/04-development/01-architecture.md b/docs/04-development/01-architecture.md
new file mode 100644
index 0000000..e7f6d27
--- /dev/null
+++ b/docs/04-development/01-architecture.md
@@ -0,0 +1,137 @@
+# 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
deleted file mode 100644
index b5020c8..0000000
--- a/docs/04-development/01-plugin-system.md
+++ /dev/null
@@ -1,648 +0,0 @@
-# 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
new file mode 100644
index 0000000..1163dc6
--- /dev/null
+++ b/docs/04-development/02-content-system.md
@@ -0,0 +1,126 @@
+# 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
deleted file mode 100644
index abd49b6..0000000
--- a/docs/04-development/02-creating-templates.md
+++ /dev/null
@@ -1,719 +0,0 @@
-# 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
new file mode 100644
index 0000000..3500297
--- /dev/null
+++ b/docs/04-development/03-configuration.md
@@ -0,0 +1,114 @@
+# 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
new file mode 100644
index 0000000..932113e
--- /dev/null
+++ b/docs/04-development/04-context-api.md
@@ -0,0 +1,71 @@
+# 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
new file mode 100644
index 0000000..d7c668e
--- /dev/null
+++ b/docs/04-development/05-hooks-plugins.md
@@ -0,0 +1,140 @@
+# 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
new file mode 100644
index 0000000..4ffde03
--- /dev/null
+++ b/docs/04-development/06-templates.md
@@ -0,0 +1,144 @@
+# 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
new file mode 100644
index 0000000..2fe68c4
--- /dev/null
+++ b/docs/04-development/07-rendering.md
@@ -0,0 +1,104 @@
+# 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
new file mode 100644
index 0000000..e34831f
--- /dev/null
+++ b/docs/04-development/08-dev-environment.md
@@ -0,0 +1,130 @@
+# 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.