Compare commits

...

2 commits

Author SHA1 Message Date
Ruben
b03511f99b Update AGENT.md and add architecture documentation
Update AGENT.md to reflect current project structure and philosophy

Add comprehensive architecture documentation covering:
- Directory layout
- Stable contracts
- Request flow
- Module dependencies
- Page vs list detection
- Deployment models
- Demo fallback

Remove outdated plugin system documentation
Add new content system documentation
Add configuration documentation
Add context API documentation
Add hooks and plugins documentation
Add templates documentation
Add rendering documentation
Add development environment documentation
2026-02-05 23:30:44 +01:00
Ruben
696b0ad801 Claenup
Add page CSS URL and hash to template variables

Move page CSS handling from rendering to router
2026-02-05 23:30:20 +01:00
13 changed files with 1003 additions and 1442 deletions

View file

@ -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/`.

View file

@ -64,63 +64,6 @@ 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, "<article><h1>403 Forbidden</h1><p>Access denied.</p></article>", 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, "<article><h1>404 - Not Found</h1><p>The requested file could not be found.</p></article>", 404);
}
function renderMultipleFiles(Context $ctx, array $files, string $pageDir): void {
// Load metadata and page plugins BEFORE rendering content files

View file

@ -110,9 +110,6 @@ 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";
@ -186,6 +183,8 @@ 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, [
@ -193,7 +192,8 @@ switch ($parsedPath['type']) {
'homeLabel' => $homeLabel,
'pageTitle' => $pageTitle,
'metaDescription' => $metaDescription,
'pageCss' => $pageCss,
'pageCssUrl' => $pageCssUrl,
'pageCssHash' => $pageCssHash,
'items' => $items,
'pageContent' => $pageContent
], $ctx);

View file

@ -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.

View file

@ -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
<?php
// Add reading time to template variables
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
// Only calculate if we have content
if (isset($vars['content'])) {
$wordCount = str_word_count(strip_tags($vars['content']));
$wordsPerMinute = 200;
$readingTime = max(1, round($wordCount / $wordsPerMinute));
$vars['readingTime'] = $readingTime;
$vars['wordCount'] = $wordCount;
}
return $vars;
});
```
### Step 2: Enable the Plugin
**custom/config.ini:**
```ini
[plugins]
enabled = "languages,reading-time"
```
### Step 3: Use in Template
**custom/templates/page.php:**
```php
<article>
<header>
<h1><?= htmlspecialchars($metadata['title'] ?? 'Untitled') ?></h1>
<?php if (isset($readingTime)): ?>
<p class="reading-time">
<?= $readingTime ?> min read (<?= number_format($wordCount) ?> words)
</p>
<?php endif; ?>
</header>
<div class="content">
<?= $content ?>
</div>
</article>
```
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
<?php
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
global $config;
// Read tracking ID from config
$trackingId = $config['analytics']['tracking_id'] ?? null;
if ($trackingId) {
$vars['analyticsId'] = $trackingId;
}
return $vars;
});
```
**custom/config.ini:**
```ini
[analytics]
tracking_id = "G-XXXXXXXXXX"
[plugins]
enabled = "languages,analytics"
```
**custom/templates/base.php:**
```php
<head>
<!-- ... -->
<?php if (isset($analyticsId)): ?>
<script async src="https://www.googletagmanager.com/gtag/js?id=<?= htmlspecialchars($analyticsId) ?>"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '<?= htmlspecialchars($analyticsId) ?>');
</script>
<?php endif; ?>
</head>
```
### Table of Contents Plugin
Generate a table of contents from headings.
**custom/plugins/global/toc.php:**
```php
<?php
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
if (!isset($vars['content'])) {
return $vars;
}
$content = $vars['content'];
$toc = [];
// Extract headings
if (preg_match_all('/<h([2-3])>(.*?)<\/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('<h' . $level . '>', '<h' . $level . ' id="' . $id . '">', $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
<?php if (!empty($tableOfContents)): ?>
<nav class="toc">
<h2>Table of Contents</h2>
<ul>
<?php foreach ($tableOfContents as $item): ?>
<li class="toc-level-<?= $item['level'] ?>">
<a href="#<?= htmlspecialchars($item['id']) ?>">
<?= htmlspecialchars($item['text']) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</nav>
<?php endif; ?>
<article>
<?= $content ?>
</article>
```
### Author Bio Plugin
Add author information from metadata.
**custom/plugins/global/author-bio.php:**
```php
<?php
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
$metadata = $ctx->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
<?php if (isset($authorBio)): ?>
<aside class="author-bio">
<h3><?= htmlspecialchars($authorBio['name'] ?? 'Unknown') ?></h3>
<p><?= htmlspecialchars($authorBio['bio'] ?? '') ?></p>
<?php if (isset($authorBio['website'])): ?>
<a href="<?= htmlspecialchars($authorBio['website']) ?>">Website</a>
<?php endif; ?>
</aside>
<?php endif; ?>
```
### Related Posts Plugin
Show related posts based on tags.
**custom/plugins/global/related-posts.php:**
```php
<?php
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
$metadata = $ctx->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
<?php if (!empty($relatedPosts)): ?>
<aside class="related-posts">
<h3>Related Posts</h3>
<ul>
<?php foreach ($relatedPosts as $post): ?>
<li>
<a href="<?= htmlspecialchars($post['url']) ?>">
<?= htmlspecialchars($post['title']) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</aside>
<?php endif; ?>
```
## 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
<?php
error_log("My plugin loaded!");
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
error_log("TEMPLATE_VARS hook called");
error_log("Variables: " . print_r(array_keys($vars), true));
$vars['debug'] = 'Plugin is working';
return $vars;
});
```
Check the error log:
```bash
tail -f /var/log/apache2/error.log
```
### Inspect Hook Order
Hooks execute in the order they're registered. Check order by logging:
```php
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
error_log("Hook 1: " . json_encode(array_keys($vars)));
return $vars;
});
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
error_log("Hook 2: " . json_encode(array_keys($vars)));
return $vars;
});
```
### Dump Variables in Templates
Temporarily add to your template:
```php
<pre><?php var_dump($customVariable); ?></pre>
```
**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

View file

@ -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, `<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` | `<meta name="description">` |
| `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 `<h1>` tag. Returns null if no heading found.

View file

@ -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
<!DOCTYPE html>
<html lang="<?= htmlspecialchars($currentLang ?? 'en') ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle ?? 'My Site') ?></title>
<?php if (!empty($metaDescription)): ?>
<meta name="description" content="<?= htmlspecialchars($metaDescription) ?>">
<?php endif; ?>
<!-- Open Graph -->
<meta property="og:title" content="<?= htmlspecialchars($pageTitle ?? 'My Site') ?>">
<?php if (!empty($metaDescription)): ?>
<meta property="og:description" content="<?= htmlspecialchars($metaDescription) ?>">
<?php endif; ?>
<?php if (!empty($socialImageUrl)): ?>
<meta property="og:image" content="<?= htmlspecialchars($socialImageUrl) ?>">
<?php endif; ?>
<!-- Styles -->
<link rel="stylesheet" href="/custom/styles/base.css">
<?php if (!empty($pageCssUrl)): ?>
<link rel="stylesheet" href="<?= htmlspecialchars($pageCssUrl) ?>?v=<?= htmlspecialchars($pageCssHash ?? '') ?>">
<?php endif; ?>
</head>
<body>
<a href="#main" class="skip-link">Skip to main content</a>
<header class="site-header">
<div class="container">
<a href="<?= htmlspecialchars($langPrefix ?? '') ?>/" class="site-title">
My Website
</a>
<nav class="main-nav" aria-label="Main navigation">
<ul>
<?php foreach ($navigation ?? [] as $item): ?>
<li>
<a href="<?= htmlspecialchars($item['url']) ?>">
<?= htmlspecialchars($item['title']) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</nav>
<?php if (!empty($languageUrls) && count($languageUrls) > 1): ?>
<nav class="language-switcher" aria-label="Language">
<?php foreach ($languageUrls as $lang => $url): ?>
<a href="<?= htmlspecialchars($url) ?>"
<?= ($lang === ($currentLang ?? 'en')) ? 'aria-current="true"' : '' ?>>
<?= htmlspecialchars(strtoupper($lang)) ?>
</a>
<?php endforeach; ?>
</nav>
<?php endif; ?>
</div>
</header>
<main id="main" class="site-main">
<div class="container">
<?= $content ?>
</div>
</main>
<footer class="site-footer">
<div class="container">
<nav aria-label="Footer navigation">
<a href="/privacy/">Privacy</a>
<a href="/terms/">Terms</a>
<a href="/contact/">Contact</a>
</nav>
<p class="copyright">
&copy; <?= date('Y') ?> My Website
</p>
<p class="performance">
<?= htmlspecialchars($translations['footer_handcoded'] ?? 'Generated in') ?>
<?= number_format((microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']) * 1000, 2) ?>
<?= htmlspecialchars($translations['footer_page_time'] ?? 'ms') ?>
</p>
</div>
</footer>
</body>
</html>
```
### 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
<article class="blog-post">
<header class="post-header">
<?php if (isset($metadata['title'])): ?>
<h1><?= htmlspecialchars($metadata['title']) ?></h1>
<?php endif; ?>
<div class="post-meta">
<?php if (isset($metadata['date']) && ($metadata['show_date'] ?? true)): ?>
<time datetime="<?= $metadata['date'] ?>">
<?= $metadata['formatted_date'] ?? $metadata['date'] ?>
</time>
<?php endif; ?>
<?php if (isset($metadata['author'])): ?>
<span class="author">
by <?= htmlspecialchars($metadata['author']) ?>
</span>
<?php endif; ?>
<?php if (isset($readingTime)): ?>
<span class="reading-time">
<?= $readingTime ?> min read
</span>
<?php endif; ?>
</div>
<?php if (isset($metadata['tags'])): ?>
<div class="post-tags">
<?php foreach (explode(',', $metadata['tags']) as $tag): ?>
<span class="tag"><?= htmlspecialchars(trim($tag)) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</header>
<div class="post-content">
<?= $content ?>
</div>
<?php if (!empty($relatedPosts)): ?>
<aside class="related-posts">
<h2>Related Posts</h2>
<ul>
<?php foreach ($relatedPosts as $post): ?>
<li>
<a href="<?= htmlspecialchars($post['url']) ?>">
<?= htmlspecialchars($post['title']) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</aside>
<?php endif; ?>
</article>
```
### Portfolio Item Template
**custom/templates/page-portfolio.php:**
```php
<article class="portfolio-item">
<?php if (isset($metadata['cover_image'])): ?>
<div class="project-hero">
<img src="<?= $metadata['cover_image'] ?>"
alt="<?= htmlspecialchars($metadata['title'] ?? '') ?>">
</div>
<?php endif; ?>
<header class="project-header">
<h1><?= htmlspecialchars($metadata['title'] ?? 'Untitled') ?></h1>
<dl class="project-details">
<?php if (isset($metadata['client'])): ?>
<dt>Client</dt>
<dd><?= htmlspecialchars($metadata['client']) ?></dd>
<?php endif; ?>
<?php if (isset($metadata['year'])): ?>
<dt>Year</dt>
<dd><?= htmlspecialchars($metadata['year']) ?></dd>
<?php endif; ?>
<?php if (isset($metadata['role'])): ?>
<dt>Role</dt>
<dd><?= htmlspecialchars($metadata['role']) ?></dd>
<?php endif; ?>
</dl>
</header>
<div class="project-content">
<?= $content ?>
</div>
<?php if (isset($metadata['project_url'])): ?>
<footer class="project-footer">
<a href="<?= htmlspecialchars($metadata['project_url']) ?>"
class="button" target="_blank" rel="noopener">
View Live Project →
</a>
</footer>
<?php endif; ?>
</article>
```
**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
<?php if ($pageContent): ?>
<div class="list-intro">
<?= $pageContent ?>
</div>
<?php endif; ?>
<div class="card-grid">
<?php foreach ($items as $item): ?>
<article class="card">
<?php if (isset($item['cover_image'])): ?>
<a href="<?= $item['url'] ?>" class="card-image">
<img src="<?= $item['cover_image'] ?>"
alt="<?= htmlspecialchars($item['title']) ?>"
loading="lazy">
</a>
<?php endif; ?>
<div class="card-content">
<h2 class="card-title">
<a href="<?= $item['url'] ?>">
<?= htmlspecialchars($item['title']) ?>
</a>
</h2>
<?php if (isset($item['date'])): ?>
<time class="card-date" datetime="<?= $item['date'] ?>">
<?= $item['formatted_date'] ?? $item['date'] ?>
</time>
<?php endif; ?>
<?php if (isset($item['summary'])): ?>
<p class="card-summary">
<?= htmlspecialchars($item['summary']) ?>
</p>
<?php endif; ?>
<a href="<?= $item['url'] ?>" class="card-link">
Read more →
</a>
</div>
</article>
<?php endforeach; ?>
</div>
```
**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 ?>
<div class="timeline">
<?php
$currentYear = null;
foreach ($items as $item):
// Extract year from date
$year = isset($item['date']) ? date('Y', strtotime($item['date'])) : null;
// Show year marker when year changes
if ($year && $year !== $currentYear):
$currentYear = $year;
?>
<div class="timeline-year">
<h2><?= $year ?></h2>
</div>
<?php endif; ?>
<article class="timeline-item">
<time class="timeline-date">
<?= $item['formatted_date'] ?? ($item['date'] ?? '') ?>
</time>
<div class="timeline-content">
<h3>
<a href="<?= $item['url'] ?>">
<?= htmlspecialchars($item['title']) ?>
</a>
</h3>
<?php if (isset($item['summary'])): ?>
<p><?= htmlspecialchars($item['summary']) ?></p>
<?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</div>
```
**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 ?>
<?php if (!empty($items)): ?>
<div class="magazine-layout">
<!-- Featured post (first item) -->
<?php $featured = array_shift($items); ?>
<article class="magazine-featured">
<?php if (isset($featured['cover_image'])): ?>
<a href="<?= $featured['url'] ?>" class="featured-image">
<img src="<?= $featured['cover_image'] ?>"
alt="<?= htmlspecialchars($featured['title']) ?>">
</a>
<?php endif; ?>
<div class="featured-content">
<h2>
<a href="<?= $featured['url'] ?>">
<?= htmlspecialchars($featured['title']) ?>
</a>
</h2>
<?php if (isset($featured['summary'])): ?>
<p class="featured-summary">
<?= htmlspecialchars($featured['summary']) ?>
</p>
<?php endif; ?>
<a href="<?= $featured['url'] ?>" class="read-more">
Read article →
</a>
</div>
</article>
<!-- Remaining posts in grid -->
<?php if (!empty($items)): ?>
<div class="magazine-grid">
<?php foreach ($items as $item): ?>
<article class="magazine-item">
<?php if (isset($item['cover_image'])): ?>
<a href="<?= $item['url'] ?>">
<img src="<?= $item['cover_image'] ?>"
alt="<?= htmlspecialchars($item['title']) ?>"
loading="lazy">
</a>
<?php endif; ?>
<h3>
<a href="<?= $item['url'] ?>">
<?= htmlspecialchars($item['title']) ?>
</a>
</h3>
<?php if (isset($item['date'])): ?>
<time datetime="<?= $item['date'] ?>">
<?= $item['formatted_date'] ?? $item['date'] ?>
</time>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
```
## Using Partials (Template Includes)
Break complex templates into reusable components.
### Creating a Partial
**custom/templates/partials/post-card.php:**
```php
<article class="post-card">
<?php if (isset($post['cover_image'])): ?>
<a href="<?= $post['url'] ?>">
<img src="<?= $post['cover_image'] ?>"
alt="<?= htmlspecialchars($post['title']) ?>">
</a>
<?php endif; ?>
<h3>
<a href="<?= $post['url'] ?>">
<?= htmlspecialchars($post['title']) ?>
</a>
</h3>
<?php if (isset($post['summary'])): ?>
<p><?= htmlspecialchars($post['summary']) ?></p>
<?php endif; ?>
</article>
```
### Using a Partial
**custom/templates/list.php:**
```php
<?= $pageContent ?>
<div class="post-list">
<?php foreach ($items as $post): ?>
<?php include __DIR__ . '/partials/post-card.php'; ?>
<?php endforeach; ?>
</div>
```
**Note:** Set `$post` before including, as the partial expects it.
## Conditional Templates
Use metadata to vary presentation.
```php
<?php if (isset($metadata['layout']) && $metadata['layout'] === 'wide'): ?>
<article class="wide-layout">
<?= $content ?>
</article>
<?php else: ?>
<article class="standard-layout">
<div class="container">
<?= $content ?>
</div>
</article>
<?php endif; ?>
```
**Set in metadata:**
```ini
[settings]
layout = "wide"
```
## Template Best Practices
### 1. Always Escape Output
```php
<!-- Good -->
<h1><?= htmlspecialchars($title) ?></h1>
<!-- Bad -->
<h1><?= $title ?></h1>
```
### 2. Check Before Using
```php
<!-- Good -->
<?php if (isset($metadata['author'])): ?>
<p>By <?= htmlspecialchars($metadata['author']) ?></p>
<?php endif; ?>
<!-- Bad -->
<p>By <?= htmlspecialchars($metadata['author']) ?></p>
```
### 3. Use Semantic HTML
```php
<!-- Good -->
<article>
<header><h1>Title</h1></header>
<div class="content">Content</div>
<footer>Meta</footer>
</article>
<!-- Bad -->
<div class="post">
<div class="title">Title</div>
<div class="content">Content</div>
</div>
```
### 4. Add ARIA Labels
```php
<nav aria-label="Main navigation">
<!-- navigation items -->
</nav>
<nav aria-label="Language">
<!-- language switcher -->
</nav>
```
### 5. Keep Logic Minimal
```php
<!-- Good: simple check -->
<?php if (isset($item['date'])): ?>
<time><?= $item['formatted_date'] ?></time>
<?php endif; ?>
<!-- Bad: complex logic (move to plugin) -->
<?php
$recentPosts = array_filter($items, fn($item) =>
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

View file

@ -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.

View file

@ -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'];
```

View file

@ -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
<?php
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx): array {
if (isset($vars['content'])) {
$words = str_word_count(strip_tags($vars['content']));
$vars['readingTime'] = max(1, round($words / 200));
}
return $vars;
});
```
Enable in `custom/config.ini`:
```ini
[plugins]
enabled = "languages,your-plugin-name"
```
### Plugin Rules
1. **Always return** the modified value from hook callbacks
2. **Prefix helper functions** to avoid collisions (`myPlugin_helper()`)
3. **Read config** via the `$config` parameter in `CONTEXT_READY`, not by reading files
4. **Check variable existence** before using (`isset()`, `??`)
5. **No inter-plugin communication** — plugins are independent
6. **Execution order** follows registration order (load order), no priority system
### Accessing Config from Plugins
```php
Hooks::add(Hook::CONTEXT_READY, function(Context $ctx, array $config): Context {
$myVal = $config['my_plugin']['key'] ?? 'default';
$ctx->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`).

View file

@ -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 (`<article><?= $content ?></article>`) |
| `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:** `<?php if (!empty($var)): ?>`
- **Use null coalescing for defaults:** `<?= $var ?? 'fallback' ?>`
- **Use short echo tags:** `<?= $expr ?>`
- **Semantic HTML5:** `<article>`, `<nav>`, `<header>`, `<footer>`, `<time>`, `<main>`
- **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
<?php foreach ($items as $post): ?>
<?php include __DIR__ . '/partials/post-card.php'; ?>
<?php endforeach; ?>
```
Variables from the parent scope are available in the included file.

View file

@ -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

View file

@ -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 <size>` | 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.