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
This commit is contained in:
parent
696b0ad801
commit
b03511f99b
11 changed files with 999 additions and 1381 deletions
47
AGENT.md
47
AGENT.md
|
|
@ -1,22 +1,41 @@
|
|||
# FolderWeb
|
||||
|
||||
Minimal file-based CMS. Folders = URLs. PHP 8.4+, no JS, no frameworks, no build tools.
|
||||
|
||||
## Philosophy
|
||||
Minimal PHP for modern conveniences. Prioritize longevity (decade-scale maintainability) by avoiding volatile dependencies. Strictly add only what's essential—readable, simple, and future-proof.
|
||||
|
||||
Decade-scale maintainability. Only essential tech. Readable, simple, future-proof. No volatile dependencies.
|
||||
|
||||
## Two Modes of Work
|
||||
|
||||
1. **Building on top** (`custom/`): Create sites using the framework. Never modify `app/`. In this mode, `app/` is typically symlinked or submoduled from the framework repo into a separate site repo.
|
||||
2. **Framework development** (`app/`): Evolve the core. Preserve all stable contracts (see architecture doc). `custom/` may be symlinked in from a site repo for testing.
|
||||
|
||||
## Core Constraints
|
||||
**Minimalism:** Only essential tech (HTML, PHP 8.4+, CSS). No JS, frameworks, build tools, or package managers. Comments only for major sections.
|
||||
|
||||
**Frontend:**
|
||||
- Classless, semantic HTML5
|
||||
- Modern CSS: nesting, `oklch()`, grid, `clamp()`, logical props
|
||||
- Responsive via fluid typography + flexible layouts
|
||||
|
||||
**Security:**
|
||||
- Path validation blocks traversal
|
||||
- Files restricted to document root
|
||||
- Strict MIME types + no direct user-input execution
|
||||
- **Stack:** HTML5, PHP 8.4+, CSS. Nothing else.
|
||||
- **Frontend:** Classless semantic HTML, modern CSS (nesting, `oklch()`, grid, `clamp()`, logical props)
|
||||
- **Security:** Path traversal protection, document root restriction, strict MIME types, escape all UGC
|
||||
|
||||
## Code Style
|
||||
**PHP:** Modern syntax (arrow functions, null coalescing, match). Type hints where practical. Ternary for simple conditionals. Single-purpose functions.
|
||||
|
||||
**CSS:** Variables, native nesting, grid layouts. `clamp()` over `@media`. Relative units > pixels.
|
||||
- **PHP:** Arrow functions, null coalescing, match expressions. Type hints where practical. Single-purpose functions. Comments only for major sections.
|
||||
- **CSS:** Variables, native nesting, grid. `clamp()` over `@media`. Relative units.
|
||||
- **Templates:** `<?= htmlspecialchars($var) ?>` for UGC. `<?= $content ?>` for pre-rendered HTML.
|
||||
|
||||
**Templates:** Escape output (`htmlspecialchars()` for UGC). Short echo tags (`<?= $var ?>`).
|
||||
## Knowledge Base
|
||||
|
||||
Read these docs on-demand when working on related areas. Do not load all at once.
|
||||
|
||||
| Skill | File | Read When |
|
||||
|---|---|---|
|
||||
| Architecture | `docs/04-development/01-architecture.md` | Understanding project structure, request flow, module dependencies, stable contracts |
|
||||
| Content System | `docs/04-development/02-content-system.md` | Working with routing, URL resolution, metadata, content discovery, navigation |
|
||||
| Configuration | `docs/04-development/03-configuration.md` | Config loading, the `custom/` override system, static asset routing |
|
||||
| Context API | `docs/04-development/04-context-api.md` | The Context class, Templates class, built-in context keys |
|
||||
| Hooks & Plugins | `docs/04-development/05-hooks-plugins.md` | Hook system, plugin manager, writing plugins, the language plugin |
|
||||
| Templates | `docs/04-development/06-templates.md` | Template hierarchy, resolution, variables, list item schema, partials |
|
||||
| Rendering | `docs/04-development/07-rendering.md` | Rendering pipeline, Markdown caching, static file serving, Parsedown |
|
||||
| Dev Environment | `docs/04-development/08-dev-environment.md` | Container setup, Apache config, performance profiling, test data generation |
|
||||
|
||||
Human-facing docs (tutorials, reference) are in `docs/01-getting-started/`, `docs/02-tutorial/`, `docs/03-reference/`.
|
||||
|
|
|
|||
137
docs/04-development/01-architecture.md
Normal file
137
docs/04-development/01-architecture.md
Normal 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.
|
||||
|
|
@ -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
|
||||
126
docs/04-development/02-content-system.md
Normal file
126
docs/04-development/02-content-system.md
Normal 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.
|
||||
|
|
@ -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">
|
||||
© <?= 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
|
||||
114
docs/04-development/03-configuration.md
Normal file
114
docs/04-development/03-configuration.md
Normal 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.
|
||||
71
docs/04-development/04-context-api.md
Normal file
71
docs/04-development/04-context-api.md
Normal 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'];
|
||||
```
|
||||
140
docs/04-development/05-hooks-plugins.md
Normal file
140
docs/04-development/05-hooks-plugins.md
Normal 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`).
|
||||
144
docs/04-development/06-templates.md
Normal file
144
docs/04-development/06-templates.md
Normal 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.
|
||||
104
docs/04-development/07-rendering.md
Normal file
104
docs/04-development/07-rendering.md
Normal 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
|
||||
130
docs/04-development/08-dev-environment.md
Normal file
130
docs/04-development/08-dev-environment.md
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue