Compare commits
2 commits
7782eefa96
...
b03511f99b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03511f99b | ||
|
|
696b0ad801 |
13 changed files with 1003 additions and 1442 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
|
## 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.
|
|
||||||
|
|
||||||
**Frontend:**
|
- **Stack:** HTML5, PHP 8.4+, CSS. Nothing else.
|
||||||
- Classless, semantic HTML5
|
- **Frontend:** Classless semantic HTML, modern CSS (nesting, `oklch()`, grid, `clamp()`, logical props)
|
||||||
- Modern CSS: nesting, `oklch()`, grid, `clamp()`, logical props
|
- **Security:** Path traversal protection, document root restriction, strict MIME types, escape all UGC
|
||||||
- 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.
|
|
||||||
|
|
||||||
**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/`.
|
||||||
|
|
|
||||||
|
|
@ -64,63 +64,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -110,9 +110,6 @@ 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";
|
||||||
|
|
||||||
|
|
@ -186,6 +183,8 @@ 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, [
|
||||||
|
|
@ -193,7 +192,8 @@ switch ($parsedPath['type']) {
|
||||||
'homeLabel' => $homeLabel,
|
'homeLabel' => $homeLabel,
|
||||||
'pageTitle' => $pageTitle,
|
'pageTitle' => $pageTitle,
|
||||||
'metaDescription' => $metaDescription,
|
'metaDescription' => $metaDescription,
|
||||||
'pageCss' => $pageCss,
|
'pageCssUrl' => $pageCssUrl,
|
||||||
|
'pageCssHash' => $pageCssHash,
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
'pageContent' => $pageContent
|
'pageContent' => $pageContent
|
||||||
], $ctx);
|
], $ctx);
|
||||||
|
|
|
||||||
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