Compare commits

..

No commits in common. "b03511f99bdbc15865fd95bea61f2e3b2ec1d48d" and "7782eefa968b5e6675589ec8cf00c7da78dea815" have entirely different histories.

13 changed files with 1442 additions and 1003 deletions

View file

@ -1,41 +1,22 @@
# FolderWeb
Minimal file-based CMS. Folders = URLs. PHP 8.4+, no JS, no frameworks, no build tools.
## Philosophy ## Philosophy
Minimal PHP for modern conveniences. Prioritize longevity (decade-scale maintainability) by avoiding volatile dependencies. Strictly add only what's essential—readable, simple, and future-proof.
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 ## Core Constraints
**Minimalism:** Only essential tech (HTML, PHP 8.4+, CSS). No JS, frameworks, build tools, or package managers. Comments only for major sections.
- **Stack:** HTML5, PHP 8.4+, CSS. Nothing else. **Frontend:**
- **Frontend:** Classless semantic HTML, modern CSS (nesting, `oklch()`, grid, `clamp()`, logical props) - Classless, semantic HTML5
- **Security:** Path traversal protection, document root restriction, strict MIME types, escape all UGC - Modern CSS: nesting, `oklch()`, grid, `clamp()`, logical props
- Responsive via fluid typography + flexible layouts
**Security:**
- Path validation blocks traversal
- Files restricted to document root
- Strict MIME types + no direct user-input execution
## Code Style ## Code Style
**PHP:** Modern syntax (arrow functions, null coalescing, match). Type hints where practical. Ternary for simple conditionals. Single-purpose functions.
- **PHP:** Arrow functions, null coalescing, match expressions. Type hints where practical. Single-purpose functions. Comments only for major sections. **CSS:** Variables, native nesting, grid layouts. `clamp()` over `@media`. Relative units > pixels.
- **CSS:** Variables, native nesting, grid. `clamp()` over `@media`. Relative units.
- **Templates:** `<?= htmlspecialchars($var) ?>` for UGC. `<?= $content ?>` for pre-rendered HTML.
## Knowledge Base **Templates:** Escape output (`htmlspecialchars()` for UGC). Short echo tags (`<?= $var ?>`).
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,6 +64,63 @@ function renderTemplate(Context $ctx, string $content, int $statusCode = 200): v
exit; 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 { function renderMultipleFiles(Context $ctx, array $files, string $pageDir): void {
// Load metadata and page plugins BEFORE rendering content files // Load metadata and page plugins BEFORE rendering content files

View file

@ -110,6 +110,9 @@ switch ($parsedPath['type']) {
$listTemplate = $ctx->templates->list; $listTemplate = $ctx->templates->list;
if (isset($metadata['page_template']) && !empty($metadata['page_template'])) { if (isset($metadata['page_template']) && !empty($metadata['page_template'])) {
$templateName = $metadata['page_template']; $templateName = $metadata['page_template'];
if (!str_ends_with($templateName, '.php')) {
$templateName .= '';
}
$customTemplate = dirname(__DIR__) . "/custom/templates/$templateName.php"; $customTemplate = dirname(__DIR__) . "/custom/templates/$templateName.php";
$defaultTemplate = __DIR__ . "/default/templates/$templateName.php"; $defaultTemplate = __DIR__ . "/default/templates/$templateName.php";
@ -183,8 +186,6 @@ switch ($parsedPath['type']) {
// Check for page-specific CSS // Check for page-specific CSS
$pageCss = findPageCss($dir, $ctx->contentDir); $pageCss = findPageCss($dir, $ctx->contentDir);
$pageCssUrl = $pageCss['url'] ?? null;
$pageCssHash = $pageCss['hash'] ?? null;
// Let plugins add template variables // Let plugins add template variables
$templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [ $templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [
@ -192,8 +193,7 @@ switch ($parsedPath['type']) {
'homeLabel' => $homeLabel, 'homeLabel' => $homeLabel,
'pageTitle' => $pageTitle, 'pageTitle' => $pageTitle,
'metaDescription' => $metaDescription, 'metaDescription' => $metaDescription,
'pageCssUrl' => $pageCssUrl, 'pageCss' => $pageCss,
'pageCssHash' => $pageCssHash,
'items' => $items, 'items' => $items,
'pageContent' => $pageContent 'pageContent' => $pageContent
], $ctx); ], $ctx);

View file

@ -1,137 +0,0 @@
# Architecture
## Directory Layout
```
app/ # Framework core — stable API surface
router.php # Entry point: all requests route here
constants.php # CONTENT_EXTENSIONS, COVER_IMAGE_EXTENSIONS
hooks.php # Hook enum + Hooks class
context.php # Context + Templates classes
config.php # createContext(): config merge + bootstrap
helpers.php # Utility functions (template resolution, extraction)
content.php # Content discovery, slug resolution, navigation
rendering.php # Markdown/HTML/PHP rendering, template wrapping
cache.php # Markdown render cache (/tmp/folderweb_cache)
plugins.php # PluginManager class
static.php # /app/* asset serving with traversal protection
vendor/ # Parsedown + ParsedownExtra (Markdown→HTML)
plugins/global/ # Built-in plugins (languages.php)
default/ # Demo/fallback theme (NOT for production use)
config.ini # Default configuration
templates/ # base.php, page.php, list.php, list-compact.php, list-grid.php
styles/ # Default stylesheet
languages/ # en.ini, no.ini
content/ # Demo content shown when custom/ has no content
custom/ # User layer — all site-specific work goes here
config.ini # Config overrides (merged over app/default/config.ini)
templates/ # Override any template by matching filename
styles/ # Stylesheets served via /app/styles/*
fonts/ # Fonts served via /app/fonts/*
assets/ # Static files served at document root (favicon, robots.txt)
languages/ # Translation overrides/additions (*.ini)
plugins/global/ # Custom global plugins
plugins/page/ # Custom page plugins (reserved, not yet active)
content/ # Website content (= document root in production)
devel/ # Dev environment (Containerfile, compose, Apache, perf tools)
docs/ # Documentation (01-03 human-facing, 04 machine-facing)
```
## Stable Contracts
**`app/` is the framework.** When developing `custom/` sites, never modify `app/`. When developing the framework itself, preserve these contracts:
| Contract | Guaranteed Behavior |
|---|---|
| Override chain | `custom/*` always takes priority over `app/default/*` for templates, styles, languages, config |
| Template names | `base.php`, `page.php`, `list.php` are the three core template names |
| Hook enum | `Hook::CONTEXT_READY`, `Hook::PROCESS_CONTENT`, `Hook::TEMPLATE_VARS` — signatures documented in `05-hooks-plugins.md` |
| Context API | `$ctx->set()`, `$ctx->get()`, `$ctx->has()` — stable key/value store |
| Content extensions | `md`, `html`, `php` — defined in `CONTENT_EXTENSIONS` |
| Config format | INI with sections, merged via `array_replace_recursive` |
| Metadata format | INI file named `metadata.ini` in content directories |
| URL structure | Folder path = URL path, with slug overrides via metadata |
| Plugin locations | `app/plugins/{scope}/` and `custom/plugins/{scope}/` |
| Asset routes | `/app/styles/*``custom/styles/`, `/app/fonts/*``custom/fonts/`, `/app/default-styles/*``app/default/styles/` |
| Trailing slash | Pages and lists enforce trailing slash via 301 redirect |
## Request Flow
```
Browser request
├─ Apache rewrite: all non-/app/ requests → app/router.php
router.php
├─ 1. Load modules (constants, hooks, context, helpers, plugins, config, content, rendering)
├─ 2. createContext()
│ ├─ Parse + merge config (default ← custom)
│ ├─ loadGlobalPlugins() → fires Hook::CONTEXT_READY
│ ├─ Determine contentDir (custom content or demo fallback)
│ ├─ Parse REQUEST_URI → requestPath
│ └─ Resolve template paths (custom fallback to default)
├─ 3. Check custom/assets/{path} → serve static file + exit
├─ 4. Check content/{path} for static asset (css/img/pdf/font) → serve + exit
├─ 5. Empty path? → frontpage: findAllContentFiles + renderMultipleFiles
└─ 6. parseRequestPath() → {type, path}
├─ "page": trailing slash redirect → findAllContentFiles → renderMultipleFiles
│ (fires Hook::PROCESS_CONTENT for file filtering)
│ (fires Hook::TEMPLATE_VARS before template render)
│ Template chain: content → page.php → base.php
├─ "list": trailing slash redirect → build items array from subdirectories
│ ├─ Check hide_list metadata → treat as page if true
│ ├─ Select list template from metadata page_template
│ ├─ For each subdir: loadMetadata, extractTitle, extractDateFromFolder, findCoverImage
│ ├─ Sort items by date (metadata `order` = ascending|descending)
│ ├─ Fire Hook::TEMPLATE_VARS
│ └─ Template chain: items → list-*.php → base.php
└─ "not_found": 404 response
```
## Module Dependency Order
Loaded sequentially in `router.php`:
```
constants.php → hooks.php → context.php → helpers.php → plugins.php → config.php → content.php → rendering.php
```
`config.php` calls `createContext()` which triggers plugin loading, so `hooks.php` and `plugins.php` must be loaded before it.
## Page vs List Detection
A resolved directory becomes a **list** if it contains subdirectories, otherwise a **page**. Override with `hide_list = true` in metadata to force page rendering on directories with children.
## Deployment Models
### Framework development (this repo)
Both `app/` and `custom/` live in the same repository. `custom/` holds demo/test overrides.
### Site development (separate repo)
The site is its own git repository containing `custom/`, content, and deployment config. `app/` is included as a symlink, git submodule, or copied directory pointing to a specific framework version. The site repo never modifies `app/`.
Typical site repo layout:
```
my-site/ # Site git repo
app/ → ../folderweb/app # Symlink to framework (or submodule, or copy)
custom/ # Site-specific templates, styles, plugins, config
content/ # Website content (often the document root)
devel/ # Site's own dev environment config (optional)
```
Either direction works: `app/` symlinked into a site repo, or `custom/` symlinked into the framework repo during development.
## Demo Fallback
When `content/` (document root) has no files, `app/default/content/` is used automatically. This is **demo mode only** — production sites always provide their own content via the document root.

View file

@ -0,0 +1,648 @@
# Plugin System
FolderWeb uses a minimal hook-based plugin system for extensibility. Plugins let you modify content, add functionality, and inject custom variables into templates—all without touching the framework code.
## How Plugins Work
Plugins are PHP files that register callbacks with one or more **hooks**:
1. **`Hook::CONTEXT_READY`** — After context is created, before routing
2. **`Hook::PROCESS_CONTENT`** — When loading/processing content
3. **`Hook::TEMPLATE_VARS`** — Before rendering templates
Each hook receives data, allows modification, and returns the modified data.
## Plugin Locations
```
app/plugins/
├── global/ # Built-in global plugins (don't modify)
│ └── languages.php
└── page/ # Built-in page plugins (empty by default)
custom/plugins/
├── global/ # Your global plugins
│ ├── analytics.php
│ └── reading-time.php
└── page/ # Your page plugins (not yet used)
```
**Global plugins:** Loaded on every request
**Page plugins:** Reserved for future use
## Enabling Plugins
List enabled plugins in `custom/config.ini`:
```ini
[plugins]
enabled = "languages,analytics,reading-time"
```
Plugin names correspond to filenames without `.php`:
- `languages``languages.php`
- `analytics``analytics.php`
- `reading-time``reading-time.php`
FolderWeb loads plugins from:
1. `app/plugins/global/` (built-in)
2. `custom/plugins/global/` (yours)
## The Three Hooks
### `Hook::CONTEXT_READY`
Called after the context object is created, before routing begins.
**Use for:**
- Setting global context values
- Processing configuration
- Adding cross-cutting concerns
**Signature:**
```php
Hooks::add(Hook::CONTEXT_READY, function(Context $ctx, array $config) {
// Modify context
$ctx->set('key', 'value');
// Must return context
return $ctx;
});
```
**Parameters:**
- `$ctx` — Context object (see [Context API](#context-api))
- `$config` — Merged configuration array from `config.ini`
**Must return:** Modified `$ctx`
### `Hook::PROCESS_CONTENT`
Called when loading or processing content (files, metadata, dates).
**Use for:**
- Filtering content files
- Transforming metadata
- Custom content processing
**Signature:**
```php
Hooks::add(Hook::PROCESS_CONTENT, function(mixed $data, string $dirOrType, string $extraContext = '') {
// Process data based on type
if ($extraContext === 'metadata') {
// Modify metadata array
$data['custom_field'] = 'value';
}
// Must return data
return $data;
});
```
**Parameters:**
- `$data` — The data being processed (type varies)
- `$dirOrType` — Directory path or processing type
- `$extraContext` — Additional context (e.g., `"metadata"`, `"date_format"`)
**Must return:** Modified `$data`
**Common `$extraContext` values:**
- `"metadata"` — Processing metadata array
- `"date_format"` — Formatting a date string
### `Hook::TEMPLATE_VARS`
Called before rendering templates, allowing you to add variables.
**Use for:**
- Adding custom template variables
- Computing values for display
- Injecting data into templates
**Signature:**
```php
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
// Add custom variables
$vars['siteName'] = 'My Website';
$vars['currentYear'] = date('Y');
// Must return vars
return $vars;
});
```
**Parameters:**
- `$vars` — Array of template variables
- `$ctx` — Context object
**Must return:** Modified `$vars` array
## Context API
The `Context` object stores global state. Access it in hooks:
```php
// Set a value
$ctx->set('key', 'value');
// Get a value
$value = $ctx->get('key');
// Get with default
$value = $ctx->get('key', 'default');
// Check if exists
if ($ctx->has('key')) {
// ...
}
```
**Built-in context values:**
| Key | Type | Description |
|-----|------|-------------|
| `requestPath` | String | URL path (e.g., `"blog/my-post"`) |
| `contentDir` | String | Filesystem path to content |
| `currentLang` | String | Current language (from languages plugin) |
| `defaultLang` | String | Default language |
| `translations` | Array | Translated strings |
| `metadata` | Array | Current page metadata |
## Creating Your First Plugin
Let's create a plugin that adds a reading time estimate to posts.
### Step 1: Create the Plugin File
**custom/plugins/global/reading-time.php:**
```php
<?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

@ -1,126 +0,0 @@
# Content System
## Content Discovery
**`findAllContentFiles(string $dir): array`** — Returns sorted file paths from `$dir`.
- Scans for files with extensions in `CONTENT_EXTENSIONS` (`md`, `html`, `php`)
- Skips `index.php` (reserved for server entry points)
- Fires `Hook::PROCESS_CONTENT($files, $dir)` — plugins filter files (e.g., language variants)
- Sorts by filename via `strnatcmp` (natural sort: `00-hero`, `01-intro`, `02-body`)
- Returns flat array of absolute paths
**File ordering convention:** Prefix filenames with `NN-` for explicit ordering. Files without prefix sort after numbered files.
## URL Routing
**Trailing slash enforcement:** All page and list URLs are canonicalized with a trailing slash. Requests without one receive a 301 redirect (e.g., `/about``/about/`). The frontpage (`/`) is exempt.
**Frontpage handling:** Empty request path is handled before `parseRequestPath()` — it renders the content root directly via `renderMultipleFiles()`. The `"frontpage"` type from `parseRequestPath()` is never reached in the router's switch statement.
**`parseRequestPath(Context $ctx): array`** — Returns `['type' => string, 'path' => string]`.
Types:
- `"frontpage"` — empty request path (handled before this function is called)
- `"page"` — resolved directory with no subdirectories
- `"list"` — resolved directory with subdirectories
- `"not_found"` — slug resolution failed
**`resolveSlugToFolder(string $parentDir, string $slug): ?string`** — Matches URL slug to directory name.
Resolution order:
1. Exact folder name match (`$slug === $item`)
2. Metadata slug match (`metadata.ini` `slug` field)
Each URL segment is resolved independently, walking the directory tree. This enables language-specific slugs (e.g., `/no/om/``content/about/` via `[no] slug = "om"`).
## Metadata
**`loadMetadata(string $dirPath): ?array`** — Parses `metadata.ini` if present.
Returns flat key-value array with a special `_raw` key containing the full parsed INI structure (including sections). The `_raw` key is an internal contract — the language plugin reads `$data['_raw'][$lang]` to merge language-specific overrides. Plugins processing metadata **must preserve `_raw`** if present. Fires `Hook::PROCESS_CONTENT($metadata, $dirPath, 'metadata')`.
### Core Fields
| Field | Type | Default | Purpose |
|---|---|---|---|
| `title` | string | First `# heading` or folder name | Page title, list item title, `<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

@ -0,0 +1,719 @@
# Creating Custom Templates
Templates control the HTML structure and presentation of your content. This guide covers advanced template creation, from simple page layouts to complex list views.
## Template Hierarchy
FolderWeb uses a three-level template system:
1. **Base template** (`base.php`) — The HTML scaffold wrapping everything
2. **Content template** — Either `page.php` or a list template
3. **Partials** (optional) — Reusable components you create
```
base.php
└── page.php or list.php
└── Rendered content
```
## Template Resolution
When rendering a page, FolderWeb looks for templates in this order:
**For page views:**
1. `custom/templates/page.php`
2. `app/default/templates/page.php` (fallback)
**For list views:**
1. `custom/templates/{page_template}.php` (e.g., `list-grid.php`)
2. `custom/templates/list.php`
3. `app/default/templates/{page_template}.php`
4. `app/default/templates/list.php` (fallback)
**For base:**
1. `custom/templates/base.php`
2. `app/default/templates/base.php` (fallback)
## Creating a Custom Base Template
The base template defines the HTML structure for every page.
### Step 1: Copy the Default
```bash
cp app/default/templates/base.php custom/templates/base.php
```
### Step 2: Customize
**custom/templates/base.php:**
```php
<!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

@ -1,114 +0,0 @@
# Configuration
## Config Loading
**`createContext()`** in `app/config.php` handles the full bootstrap:
1. Parse `app/default/config.ini` (framework defaults)
2. If `custom/config.ini` exists, merge via `array_replace_recursive` (custom wins)
3. Load global plugins using merged config
4. Determine content directory
5. Create `Context` object
6. Fire `Hook::CONTEXT_READY` — plugins receive the merged `$config` array
## INI Format
Standard PHP `parse_ini_file` with sections enabled:
```ini
[languages]
default = "en"
available = "no,en"
[plugins]
enabled = "languages"
[custom_section]
key = "value"
```
## Built-in Config Keys
| Section | Key | Type | Default | Purpose |
|---|---|---|---|---|
| `languages` | `default` | string | `"en"` | Default language (no URL prefix) |
| `languages` | `available` | string | `"no,en"` | Comma-separated ISO 639-1 codes |
| `plugins` | `enabled` | string | `"languages"` | Comma-separated plugin names (without `.php`) |
All other sections are custom — define whatever your plugins need.
## The custom/ Override System
This is the primary mechanism for using FolderWeb. The `custom/` directory is the user layer:
```
custom/
config.ini # Merged over app/default/config.ini
templates/ # Override by matching filename (e.g., base.php, page.php, list.php)
styles/ # Served at /app/styles/*
fonts/ # Served at /app/fonts/*
assets/ # Served at document root (favicon.ico, robots.txt, etc.)
languages/ # Merged over app/default/languages/*.ini
plugins/
global/ # Loaded alongside app/plugins/global/
page/ # Reserved for future use
```
### Override Resolution Order
| Resource | Lookup | Fallback |
|---|---|---|
| Templates | `custom/templates/{name}.php` | `app/default/templates/{name}.php` |
| Styles | `custom/styles/*` via `/app/styles/*` | `app/default/styles/*` via `/app/default-styles/*` |
| Languages | `custom/languages/{lang}.ini` merged over | `app/default/languages/{lang}.ini` |
| Config | `custom/config.ini` merged over | `app/default/config.ini` |
| Plugins | `custom/plugins/{scope}/{name}.php` | `app/plugins/{scope}/{name}.php` |
For plugins, custom takes priority: if both `custom/plugins/global/foo.php` and `app/plugins/global/foo.php` exist, the custom version loads.
For full template resolution details, see `06-templates.md` "Template Resolution (canonical reference)".
### Static Asset Routing
The router checks these locations before content routing:
1. `custom/assets/{requestPath}` — served with `mime_content_type()`
2. `{contentDir}/{requestPath}` — served with explicit MIME map for: `css`, `jpg`, `jpeg`, `png`, `gif`, `webp`, `svg`, `pdf`, `woff`, `woff2`, `ttf`, `otf`
`/app/*` requests are handled by `static.php`:
- `/app/styles/*``custom/styles/*`
- `/app/fonts/*``custom/fonts/*`
- `/app/assets/*``custom/assets/*`
- `/app/default-styles/*``app/default/styles/*`
## Accessing Config in Plugins
The merged config array is passed to `Hook::CONTEXT_READY`:
```php
Hooks::add(Hook::CONTEXT_READY, function(Context $ctx, array $config) {
$val = $config['my_section']['my_key'] ?? 'default';
$ctx->set('my_val', $val);
return $ctx;
});
```
Config is **not** directly available in templates. Expose values via `Hook::TEMPLATE_VARS`:
```php
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
global $config;
$vars['siteTitle'] = $config['site']['title'] ?? 'My Site';
return $vars;
});
```
## Content Directory Resolution
In `createContext()`:
1. `$_SERVER['DOCUMENT_ROOT']` is checked for content (>2 entries in scandir)
2. If content exists → use document root as `contentDir`
3. If empty → fall back to `app/default/content/` (demo mode)
Production deployments set Apache's `DocumentRoot` to the content directory. The `app/` and `custom/` directories live outside the document root, accessed via Apache aliases.

View file

@ -1,71 +0,0 @@
# Context API
## Context Class
Defined in `app/context.php`. Stores request state and plugin data.
### Constructor Properties (readonly)
| Property | Type | Access | Description |
|---|---|---|---|
| `contentDir` | string | `$ctx->contentDir` | Absolute path to content root |
| `templates` | Templates | `$ctx->templates` | Resolved template paths |
| `requestPath` | string | `$ctx->requestPath` | URL path with leading/trailing slashes removed |
| `hasTrailingSlash` | bool | `$ctx->hasTrailingSlash` | Whether original request had trailing slash |
These use PHP 8.4 `private(set)` — readable but not writable from outside the class.
**Exception:** The language plugin modifies `requestPath` via reflection to strip the language prefix. This is an intentional framework-level operation.
### Computed Properties
| Property | Type | Description |
|---|---|---|
| `navigation` | array | `buildNavigation($this)` — lazy-computed on access |
| `homeLabel` | string | From root `metadata.ini` `slug` field, default `"Home"`. Note: reads `slug`, not `title` — typically set to a short label like "Home" or "Hjem" |
### Plugin Data Store
```php
$ctx->set(string $key, mixed $value): void
$ctx->get(string $key, mixed $default = null): mixed
$ctx->has(string $key): bool
```
Also supports magic property access: `$ctx->foo = 'bar'` / `$val = $ctx->foo`.
### Built-in Context Keys (set by language plugin)
| Key | Type | Set By | Description |
|---|---|---|---|
| `currentLang` | string | languages.php | Active language code (e.g., `"en"`) |
| `defaultLang` | string | languages.php | Default language from config |
| `availableLangs` | array | languages.php | All configured language codes |
| `langPrefix` | string | languages.php | URL prefix: `""` for default, `"/no"` for others |
| `translations` | array | languages.php | Merged translation strings for current language |
## Templates Class
Defined in `app/context.php`. Readonly value object.
```php
readonly class Templates {
public function __construct(
public string $base, # Path to base.php
public string $page, # Path to page.php
public string $list # Path to list.php
) {}
}
```
Resolved by `resolveTemplate()` — see `06-templates.md` "Template Resolution (canonical reference)" for the full lookup chain.
The list template can be overridden per-directory via `page_template` in metadata — this is resolved at render time in `router.php`, not stored in the Templates object.
## Global Access
Context is stored in `$GLOBALS['ctx']` after creation. Plugins that need context outside hook callbacks access it via:
```php
$ctx = $GLOBALS['ctx'];
```

View file

@ -1,140 +0,0 @@
# Hooks & Plugins
## Hook System
Defined in `app/hooks.php`. Two constructs:
### Hook Enum
```php
enum Hook: string {
case CONTEXT_READY = 'context_ready';
case PROCESS_CONTENT = 'process_content';
case TEMPLATE_VARS = 'template_vars';
}
```
### Hooks Class
```php
Hooks::add(Hook $hook, callable $callback): void
Hooks::apply(Hook $hook, mixed $value, mixed ...$args): mixed
```
`apply` chains all registered callbacks — each receives the return value of the previous. **Callbacks must return the modified value** or the chain breaks.
## The Three Hooks
### Hook::CONTEXT_READY
**When:** After `Context` creation, before routing.
**Signature:** `function(Context $ctx, array $config): Context`
**Use:** Set context values, process config, modify request state.
**Must return:** `$ctx`
### Hook::PROCESS_CONTENT
**When:** During content loading — files, metadata, dates.
**Signature:** `function(mixed $data, string $dirOrType, string $extraContext = ''): mixed`
**Must return:** Modified `$data`
Called in these contexts:
| `$data` type | `$dirOrType` | `$extraContext` | Purpose |
|---|---|---|---|
| array of `['path','name','ext']` | directory path | `""` | Filter content files (e.g., by language) |
| array (metadata) | directory path | `"metadata"` | Transform metadata (e.g., merge language sections) |
| string (date) | `"date_format"` | `""` | Format date string |
**Warning:** The second parameter is overloaded — it is a directory path in the first two cases but a type identifier string in the third. Plugins must distinguish these cases carefully. Use `$extraContext === 'metadata'` to detect metadata processing, and `$dirOrType === 'date_format'` to detect date formatting. In all other cases, `$dirOrType` is a directory path and `$data` is the file list array.
### Hook::TEMPLATE_VARS
**When:** Before rendering any template (page or list).
**Signature:** `function(array $vars, Context $ctx): array`
**Must return:** Modified `$vars` array
This is the primary extension point for adding custom template variables.
## Plugin Manager
Defined in `app/plugins.php`. Singleton via `getPluginManager()`.
### Loading
```php
getPluginManager()->loadGlobalPlugins(array $config) // Called during createContext()
getPluginManager()->loadPagePlugins(?array $metadata) // Called before rendering
```
**Global plugins:** Loaded from config `[plugins] enabled = "name1,name2"`.
**Page plugins:** Loaded from metadata `plugins = "name1,name2"`.
### Resolution Order
For each plugin name, checks:
1. `custom/plugins/{scope}/{name}.php`
2. `app/plugins/{scope}/{name}.php`
Custom wins. Each plugin is loaded once (`require_once`).
### Introspection
```php
getPluginManager()->getLoadedPlugins(): array // Names of all loaded plugins
getPluginManager()->isLoaded(string $name): bool
getPluginManager()->getPluginInfo(string $name): ?array // ['path','scope','loaded_at']
```
## Writing a Plugin
Create `custom/plugins/global/{name}.php`:
```php
<?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

@ -1,144 +0,0 @@
# Templates
## Template Hierarchy
Three levels, rendered inside-out:
```
Content (Markdown/HTML/PHP rendered to HTML string)
→ page.php or list-*.php (wraps content in semantic markup)
→ base.php (HTML scaffold: doctype, head, header, nav, main, footer)
```
## Template Resolution (canonical reference)
**`resolveTemplate(string $name): string`** — in `helpers.php`. This is the single resolution function used throughout the framework.
1. Check `custom/templates/{name}.php`
2. Fall back to `app/default/templates/{name}.php`
The three core templates (`base`, `page`, `list`) are resolved at context creation and stored in `$ctx->templates`. Other docs reference this section for resolution behavior.
### List Template Override
In `metadata.ini`:
```ini
[settings]
page_template = "list-grid"
```
Resolution for list template override (in `router.php`, at render time):
1. `custom/templates/{page_template}.php`
2. `app/default/templates/{page_template}.php`
3. Fall back to `$ctx->templates->list` (the default resolved at context creation)
### Default Templates Provided
| Template | Purpose |
|---|---|
| `base.php` | HTML document scaffold |
| `page.php` | Single page wrapper (`<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

@ -1,104 +0,0 @@
# Rendering & Caching
## Content Rendering
**`renderContentFile(string $filePath, ?Context $ctx = null): string`**
Renders a single content file to HTML string based on extension:
| Extension | Rendering |
|---|---|
| `md` | Parsedown + ParsedownExtra → HTML. Cached. Language prefix injected into internal links. |
| `html` | Included directly (output buffered) |
| `php` | Included with `Hook::TEMPLATE_VARS` variables extracted into scope |
PHP content files receive variables from `Hook::TEMPLATE_VARS` (starting with an empty array). This includes plugin-provided variables like `$translations`, `$currentLang`, etc. However, page-level context like `$metadata` and `$pageTitle` is **not** included — those are only available in the wrapping template (page.php/list.php), not in content files.
## Page Rendering
**`renderMultipleFiles(Context $ctx, array $files, string $pageDir): void`**
Used for both frontpage and page views:
1. Load metadata for `$pageDir`
2. Load page plugins (from metadata `plugins` field)
3. Render all content files, concatenate HTML
4. Compute: `$pageTitle`, `$metaDescription`, `$pageCssUrl`/`$pageCssHash`, `$socialImageUrl`
5. Fire `Hook::TEMPLATE_VARS` with all variables
6. `extract()` variables → render `page.php` → capture output as `$content`
7. Render `base.php` with `$content` + base variables
8. `exit`
## List Rendering
Handled directly in `router.php` (not a separate function):
1. Render directory's own content files as `$pageContent`
2. Load metadata, check `hide_list`
3. Select list template from `page_template` metadata
4. Build `$items` array from subdirectories (metadata + extraction)
5. Sort items by date
6. Fire `Hook::TEMPLATE_VARS`
7. Render list template → capture as `$content`
8. Pass to `renderTemplate()` which renders `base.php`
**`renderTemplate(Context $ctx, string $content, int $statusCode = 200): void`** — Wraps content in base template. Used for list views and error pages.
## Markdown Caching
Defined in `app/cache.php`. File-based cache in `/tmp/folderweb_cache/`.
**Cache key:** `md5($filePath . $mtime . $langPrefix)`
- Invalidates when file is modified (mtime changes)
- Invalidates per-language (different link rewriting)
- No explicit TTL — entries persist until temp directory cleanup
- **Does not track plugin state** — if a plugin modifies Markdown output (e.g., via PROCESS_CONTENT on files), changing plugin config won't bust the cache. Clear `/tmp/folderweb_cache/` manually after plugin changes that affect rendered Markdown.
```php
getCachedMarkdown(string $filePath, string $langPrefix = ''): ?string
setCachedMarkdown(string $filePath, string $html, string $langPrefix = ''): void
```
## Static File Serving
### Content Assets (router.php)
Before content routing, the router serves static files from content directory with an explicit MIME type allowlist:
`css`, `jpg`, `jpeg`, `png`, `gif`, `webp`, `svg`, `pdf`, `woff`, `woff2`, `ttf`, `otf`
Files not in this list are not served as static assets. Notably, `.js` files are excluded — JavaScript must be placed in `custom/assets/` to be served (at the document root URL), or linked from an external source.
### Custom Assets (router.php)
Files in `custom/assets/` are served at the document root URL. Example: `custom/assets/favicon.ico``/favicon.ico`. Uses `mime_content_type()` for MIME detection.
### Framework Assets (static.php)
`/app/*` requests are handled by `static.php` with directory traversal protection (`../` stripped):
| URL Path | Filesystem Path |
|---|---|
| `/app/styles/*` | `custom/styles/*` |
| `/app/fonts/*` | `custom/fonts/*` |
| `/app/assets/*` | `custom/assets/*` |
| `/app/default-styles/*` | `app/default/styles/*` |
MIME types resolved from extension map, falling back to `mime_content_type()`.
## CSS Cache Busting
Page-specific CSS gets an MD5 hash appended: `?v={hash}`. Computed by `findPageCss()`. The default theme's CSS is linked directly without hash (uses browser caching).
## Parsedown
Markdown rendering uses `Parsedown` + `ParsedownExtra` from `app/vendor/`. These are the only third-party dependencies. Loaded lazily on first Markdown render.
**Internal link rewriting:** After Markdown→HTML conversion, `href="/..."` links are prefixed with the current language prefix (e.g., `/no`). This ensures Markdown links work correctly in multilingual sites.
## Error Responses
- **403:** Invalid path (outside content directory or unreadable)
- **404:** Slug resolution failed or unknown route type
- Both render via `renderTemplate()` with appropriate status code

View file

@ -1,130 +0,0 @@
# Development Environment
## Container Setup
Located in `devel/`. Uses Podman (Docker-compatible).
### Containerfile
Base image: `php:8.4.14-apache` with Xdebug for profiling.
```
FROM php:8.4.14-apache
# Xdebug installed with profile mode
# Trigger profiling: ?XDEBUG_PROFILE=1
# Output: /tmp/cachegrind.out.*
```
### compose.yaml
**Default service** (demo mode):
```yaml
default:
build: ./
container_name: folderweb-default
volumes:
- ../app:/var/www/app:z
- ./apache/custom.conf:/etc/apache2/conf-available/custom.conf:z
- ./apache/default.conf:/etc/apache2/sites-available/000-default.conf:z
ports:
- "8080:80"
```
Mounts `app/` only — uses `app/default/content/` as demo content.
**Custom service** (commented out, template for production-like setup):
```yaml
custom:
volumes:
- ../app:/var/www/app:z
- ../content:/var/www/html:z # Content as document root
- ../custom:/var/www/custom:z # Custom overrides
- ../docs:/var/www/html/docs:z # Docs served as content
ports:
- "4040:80"
```
### Starting
```bash
cd devel
podman-compose build
podman-compose up -d
# Demo: http://localhost:8080
```
## Apache Configuration
### default.conf (VirtualHost)
- `DocumentRoot /var/www/html`
- `DirectoryIndex disabled` (no auto-index)
- RewriteRule: all non-`/app/` requests → `/app/router.php`
### custom.conf (Aliases)
Maps `/app/*` URLs to filesystem:
```
Alias /app/default-styles /var/www/app/default/styles
Alias /app/styles /var/www/custom/styles
Alias /app/fonts /var/www/custom/fonts
Alias /app /var/www/app
```
More specific aliases listed first (Apache processes in order). Enables `mod_rewrite`.
### Production Deployment
The Apache config in `devel/` is a reference. Production may vary but must ensure:
1. **All requests** (except `/app/*`) rewrite to `/app/router.php`
2. **`/app/` aliased** to the `app/` directory
3. **`/app/styles/`** aliased to `custom/styles/`
4. **`/app/fonts/`** aliased to `custom/fonts/`
5. **Document root** set to the content directory
6. **`custom/`** accessible to PHP at `../custom/` relative to `app/`
7. **`DirectoryIndex disabled`** — the router handles all paths
Nginx equivalent: proxy all non-asset requests to `app/router.php`, alias `/app/` paths accordingly.
## Performance Testing
**`devel/perf.sh`** — All-in-one profiling tool using Xdebug + Podman.
### Commands
| Command | Purpose |
|---|---|
| `./perf.sh profile /url` | Profile a URL, show top 20 slowest functions |
| `./perf.sh analyze [file]` | Analyze a cachegrind file (latest if omitted) |
| `./perf.sh list` | List available cachegrind profiles |
| `./perf.sh clean` | Remove all cachegrind files |
| `./perf.sh generate <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.