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
|
||||
Minimal PHP for modern conveniences. Prioritize longevity (decade-scale maintainability) by avoiding volatile dependencies. Strictly add only what's essential—readable, simple, and future-proof.
|
||||
|
||||
Decade-scale maintainability. Only essential tech. Readable, simple, future-proof. No volatile dependencies.
|
||||
|
||||
## Two Modes of Work
|
||||
|
||||
1. **Building on top** (`custom/`): Create sites using the framework. Never modify `app/`. In this mode, `app/` is typically symlinked or submoduled from the framework repo into a separate site repo.
|
||||
2. **Framework development** (`app/`): Evolve the core. Preserve all stable contracts (see architecture doc). `custom/` may be symlinked in from a site repo for testing.
|
||||
|
||||
## Core Constraints
|
||||
**Minimalism:** Only essential tech (HTML, PHP 8.4+, CSS). No JS, frameworks, build tools, or package managers. Comments only for major sections.
|
||||
|
||||
**Frontend:**
|
||||
- Classless, semantic HTML5
|
||||
- Modern CSS: nesting, `oklch()`, grid, `clamp()`, logical props
|
||||
- Responsive via fluid typography + flexible layouts
|
||||
|
||||
**Security:**
|
||||
- Path validation blocks traversal
|
||||
- Files restricted to document root
|
||||
- Strict MIME types + no direct user-input execution
|
||||
- **Stack:** HTML5, PHP 8.4+, CSS. Nothing else.
|
||||
- **Frontend:** Classless semantic HTML, modern CSS (nesting, `oklch()`, grid, `clamp()`, logical props)
|
||||
- **Security:** Path traversal protection, document root restriction, strict MIME types, escape all UGC
|
||||
|
||||
## Code Style
|
||||
**PHP:** Modern syntax (arrow functions, null coalescing, match). Type hints where practical. Ternary for simple conditionals. Single-purpose functions.
|
||||
|
||||
**CSS:** Variables, native nesting, grid layouts. `clamp()` over `@media`. Relative units > pixels.
|
||||
- **PHP:** Arrow functions, null coalescing, match expressions. Type hints where practical. Single-purpose functions. Comments only for major sections.
|
||||
- **CSS:** Variables, native nesting, grid. `clamp()` over `@media`. Relative units.
|
||||
- **Templates:** `<?= htmlspecialchars($var) ?>` for UGC. `<?= $content ?>` for pre-rendered HTML.
|
||||
|
||||
**Templates:** Escape output (`htmlspecialchars()` for UGC). Short echo tags (`<?= $var ?>`).
|
||||
## Knowledge Base
|
||||
|
||||
Read these docs on-demand when working on related areas. Do not load all at once.
|
||||
|
||||
| Skill | File | Read When |
|
||||
|---|---|---|
|
||||
| Architecture | `docs/04-development/01-architecture.md` | Understanding project structure, request flow, module dependencies, stable contracts |
|
||||
| Content System | `docs/04-development/02-content-system.md` | Working with routing, URL resolution, metadata, content discovery, navigation |
|
||||
| Configuration | `docs/04-development/03-configuration.md` | Config loading, the `custom/` override system, static asset routing |
|
||||
| Context API | `docs/04-development/04-context-api.md` | The Context class, Templates class, built-in context keys |
|
||||
| Hooks & Plugins | `docs/04-development/05-hooks-plugins.md` | Hook system, plugin manager, writing plugins, the language plugin |
|
||||
| Templates | `docs/04-development/06-templates.md` | Template hierarchy, resolution, variables, list item schema, partials |
|
||||
| Rendering | `docs/04-development/07-rendering.md` | Rendering pipeline, Markdown caching, static file serving, Parsedown |
|
||||
| Dev Environment | `docs/04-development/08-dev-environment.md` | Container setup, Apache config, performance profiling, test data generation |
|
||||
|
||||
Human-facing docs (tutorials, reference) are in `docs/01-getting-started/`, `docs/02-tutorial/`, `docs/03-reference/`.
|
||||
|
|
|
|||
|
|
@ -64,63 +64,6 @@ function renderTemplate(Context $ctx, string $content, int $statusCode = 200): v
|
|||
exit;
|
||||
}
|
||||
|
||||
function renderFile(Context $ctx, string $filePath): void {
|
||||
$realPath = realpath($filePath);
|
||||
if (!$realPath || !str_starts_with($realPath, $ctx->contentDir) || !is_readable($realPath)) {
|
||||
renderTemplate($ctx, "<article><h1>403 Forbidden</h1><p>Access denied.</p></article>", 403);
|
||||
}
|
||||
|
||||
$ext = pathinfo($realPath, PATHINFO_EXTENSION);
|
||||
|
||||
if (in_array($ext, CONTENT_EXTENSIONS)) {
|
||||
// Load metadata and page plugins BEFORE rendering content
|
||||
// so that plugin-provided template variables are available to PHP content files
|
||||
$pageDir = dirname($realPath);
|
||||
$pageMetadata = loadMetadata($pageDir);
|
||||
getPluginManager()->loadPagePlugins($pageMetadata);
|
||||
|
||||
$content = renderContentFile($realPath, $ctx);
|
||||
|
||||
$navigation = $ctx->navigation;
|
||||
$homeLabel = $ctx->homeLabel;
|
||||
$pageTitle = $pageMetadata['title'] ?? null;
|
||||
$metaDescription = extractMetaDescription($pageDir, $pageMetadata);
|
||||
|
||||
$pageCss = findPageCss($pageDir, $ctx->contentDir);
|
||||
$pageCssUrl = $pageCss['url'] ?? null;
|
||||
$pageCssHash = $pageCss['hash'] ?? null;
|
||||
|
||||
$coverImage = findCoverImage($pageDir);
|
||||
$socialImageUrl = null;
|
||||
if ($coverImage) {
|
||||
$relativePath = str_replace($ctx->contentDir, '', $pageDir);
|
||||
$relativePath = trim($relativePath, '/');
|
||||
$socialImageUrl = '/' . ($relativePath ? $relativePath . '/' : '') . $coverImage;
|
||||
}
|
||||
|
||||
$templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [
|
||||
'content' => $content,
|
||||
'navigation' => $navigation,
|
||||
'homeLabel' => $homeLabel,
|
||||
'pageTitle' => $pageTitle,
|
||||
'metaDescription' => $metaDescription,
|
||||
'pageCssUrl' => $pageCssUrl,
|
||||
'pageCssHash' => $pageCssHash,
|
||||
'socialImageUrl' => $socialImageUrl
|
||||
], $ctx);
|
||||
|
||||
extract($templateVars);
|
||||
|
||||
ob_start();
|
||||
require $ctx->templates->page;
|
||||
$wrappedContent = ob_get_clean();
|
||||
|
||||
include $ctx->templates->base;
|
||||
exit;
|
||||
}
|
||||
|
||||
renderTemplate($ctx, "<article><h1>404 - Not Found</h1><p>The requested file could not be found.</p></article>", 404);
|
||||
}
|
||||
|
||||
function renderMultipleFiles(Context $ctx, array $files, string $pageDir): void {
|
||||
// Load metadata and page plugins BEFORE rendering content files
|
||||
|
|
|
|||
|
|
@ -110,9 +110,6 @@ switch ($parsedPath['type']) {
|
|||
$listTemplate = $ctx->templates->list;
|
||||
if (isset($metadata['page_template']) && !empty($metadata['page_template'])) {
|
||||
$templateName = $metadata['page_template'];
|
||||
if (!str_ends_with($templateName, '.php')) {
|
||||
$templateName .= '';
|
||||
}
|
||||
$customTemplate = dirname(__DIR__) . "/custom/templates/$templateName.php";
|
||||
$defaultTemplate = __DIR__ . "/default/templates/$templateName.php";
|
||||
|
||||
|
|
@ -186,6 +183,8 @@ switch ($parsedPath['type']) {
|
|||
|
||||
// Check for page-specific CSS
|
||||
$pageCss = findPageCss($dir, $ctx->contentDir);
|
||||
$pageCssUrl = $pageCss['url'] ?? null;
|
||||
$pageCssHash = $pageCss['hash'] ?? null;
|
||||
|
||||
// Let plugins add template variables
|
||||
$templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [
|
||||
|
|
@ -193,7 +192,8 @@ switch ($parsedPath['type']) {
|
|||
'homeLabel' => $homeLabel,
|
||||
'pageTitle' => $pageTitle,
|
||||
'metaDescription' => $metaDescription,
|
||||
'pageCss' => $pageCss,
|
||||
'pageCssUrl' => $pageCssUrl,
|
||||
'pageCssHash' => $pageCssHash,
|
||||
'items' => $items,
|
||||
'pageContent' => $pageContent
|
||||
], $ctx);
|
||||
|
|
|
|||
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