Add getting started documentation
Add tutorial on adding content Add tutorial on styling Add tutorial on templates Add configuration reference Add metadata reference Add template variables reference Add internationalization reference Add plugin system documentation Add creating templates documentation Add index page
This commit is contained in:
parent
0e19040473
commit
76697e4656
11 changed files with 4724 additions and 0 deletions
648
docs/04-development/01-plugin-system.md
Normal file
648
docs/04-development/01-plugin-system.md
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
# Plugin System
|
||||
|
||||
FolderWeb uses a minimal hook-based plugin system for extensibility. Plugins let you modify content, add functionality, and inject custom variables into templates—all without touching the framework code.
|
||||
|
||||
## How Plugins Work
|
||||
|
||||
Plugins are PHP files that register callbacks with one or more **hooks**:
|
||||
|
||||
1. **`Hook::CONTEXT_READY`** — After context is created, before routing
|
||||
2. **`Hook::PROCESS_CONTENT`** — When loading/processing content
|
||||
3. **`Hook::TEMPLATE_VARS`** — Before rendering templates
|
||||
|
||||
Each hook receives data, allows modification, and returns the modified data.
|
||||
|
||||
## Plugin Locations
|
||||
|
||||
```
|
||||
app/plugins/
|
||||
├── global/ # Built-in global plugins (don't modify)
|
||||
│ └── languages.php
|
||||
└── page/ # Built-in page plugins (empty by default)
|
||||
|
||||
custom/plugins/
|
||||
├── global/ # Your global plugins
|
||||
│ ├── analytics.php
|
||||
│ └── reading-time.php
|
||||
└── page/ # Your page plugins (not yet used)
|
||||
```
|
||||
|
||||
**Global plugins:** Loaded on every request
|
||||
**Page plugins:** Reserved for future use
|
||||
|
||||
## Enabling Plugins
|
||||
|
||||
List enabled plugins in `custom/config.ini`:
|
||||
|
||||
```ini
|
||||
[plugins]
|
||||
enabled = "languages,analytics,reading-time"
|
||||
```
|
||||
|
||||
Plugin names correspond to filenames without `.php`:
|
||||
- `languages` → `languages.php`
|
||||
- `analytics` → `analytics.php`
|
||||
- `reading-time` → `reading-time.php`
|
||||
|
||||
FolderWeb loads plugins from:
|
||||
1. `app/plugins/global/` (built-in)
|
||||
2. `custom/plugins/global/` (yours)
|
||||
|
||||
## The Three Hooks
|
||||
|
||||
### `Hook::CONTEXT_READY`
|
||||
|
||||
Called after the context object is created, before routing begins.
|
||||
|
||||
**Use for:**
|
||||
- Setting global context values
|
||||
- Processing configuration
|
||||
- Adding cross-cutting concerns
|
||||
|
||||
**Signature:**
|
||||
|
||||
```php
|
||||
Hooks::add(Hook::CONTEXT_READY, function(Context $ctx, array $config) {
|
||||
// Modify context
|
||||
$ctx->set('key', 'value');
|
||||
|
||||
// Must return context
|
||||
return $ctx;
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `$ctx` — Context object (see [Context API](#context-api))
|
||||
- `$config` — Merged configuration array from `config.ini`
|
||||
|
||||
**Must return:** Modified `$ctx`
|
||||
|
||||
### `Hook::PROCESS_CONTENT`
|
||||
|
||||
Called when loading or processing content (files, metadata, dates).
|
||||
|
||||
**Use for:**
|
||||
- Filtering content files
|
||||
- Transforming metadata
|
||||
- Custom content processing
|
||||
|
||||
**Signature:**
|
||||
|
||||
```php
|
||||
Hooks::add(Hook::PROCESS_CONTENT, function(mixed $data, string $dirOrType, string $extraContext = '') {
|
||||
// Process data based on type
|
||||
if ($extraContext === 'metadata') {
|
||||
// Modify metadata array
|
||||
$data['custom_field'] = 'value';
|
||||
}
|
||||
|
||||
// Must return data
|
||||
return $data;
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `$data` — The data being processed (type varies)
|
||||
- `$dirOrType` — Directory path or processing type
|
||||
- `$extraContext` — Additional context (e.g., `"metadata"`, `"date_format"`)
|
||||
|
||||
**Must return:** Modified `$data`
|
||||
|
||||
**Common `$extraContext` values:**
|
||||
- `"metadata"` — Processing metadata array
|
||||
- `"date_format"` — Formatting a date string
|
||||
|
||||
### `Hook::TEMPLATE_VARS`
|
||||
|
||||
Called before rendering templates, allowing you to add variables.
|
||||
|
||||
**Use for:**
|
||||
- Adding custom template variables
|
||||
- Computing values for display
|
||||
- Injecting data into templates
|
||||
|
||||
**Signature:**
|
||||
|
||||
```php
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
// Add custom variables
|
||||
$vars['siteName'] = 'My Website';
|
||||
$vars['currentYear'] = date('Y');
|
||||
|
||||
// Must return vars
|
||||
return $vars;
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `$vars` — Array of template variables
|
||||
- `$ctx` — Context object
|
||||
|
||||
**Must return:** Modified `$vars` array
|
||||
|
||||
## Context API
|
||||
|
||||
The `Context` object stores global state. Access it in hooks:
|
||||
|
||||
```php
|
||||
// Set a value
|
||||
$ctx->set('key', 'value');
|
||||
|
||||
// Get a value
|
||||
$value = $ctx->get('key');
|
||||
|
||||
// Get with default
|
||||
$value = $ctx->get('key', 'default');
|
||||
|
||||
// Check if exists
|
||||
if ($ctx->has('key')) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Built-in context values:**
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `requestPath` | String | URL path (e.g., `"blog/my-post"`) |
|
||||
| `contentDir` | String | Filesystem path to content |
|
||||
| `currentLang` | String | Current language (from languages plugin) |
|
||||
| `defaultLang` | String | Default language |
|
||||
| `translations` | Array | Translated strings |
|
||||
| `metadata` | Array | Current page metadata |
|
||||
|
||||
## Creating Your First Plugin
|
||||
|
||||
Let's create a plugin that adds a reading time estimate to posts.
|
||||
|
||||
### Step 1: Create the Plugin File
|
||||
|
||||
**custom/plugins/global/reading-time.php:**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// Add reading time to template variables
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
// Only calculate if we have content
|
||||
if (isset($vars['content'])) {
|
||||
$wordCount = str_word_count(strip_tags($vars['content']));
|
||||
$wordsPerMinute = 200;
|
||||
$readingTime = max(1, round($wordCount / $wordsPerMinute));
|
||||
|
||||
$vars['readingTime'] = $readingTime;
|
||||
$vars['wordCount'] = $wordCount;
|
||||
}
|
||||
|
||||
return $vars;
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Enable the Plugin
|
||||
|
||||
**custom/config.ini:**
|
||||
|
||||
```ini
|
||||
[plugins]
|
||||
enabled = "languages,reading-time"
|
||||
```
|
||||
|
||||
### Step 3: Use in Template
|
||||
|
||||
**custom/templates/page.php:**
|
||||
|
||||
```php
|
||||
<article>
|
||||
<header>
|
||||
<h1><?= htmlspecialchars($metadata['title'] ?? 'Untitled') ?></h1>
|
||||
|
||||
<?php if (isset($readingTime)): ?>
|
||||
<p class="reading-time">
|
||||
<?= $readingTime ?> min read (<?= number_format($wordCount) ?> words)
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<?= $content ?>
|
||||
</div>
|
||||
</article>
|
||||
```
|
||||
|
||||
Done! Every page now shows reading time.
|
||||
|
||||
## Plugin Examples
|
||||
|
||||
### Analytics Plugin
|
||||
|
||||
Add Google Analytics tracking ID to all pages.
|
||||
|
||||
**custom/plugins/global/analytics.php:**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
global $config;
|
||||
|
||||
// Read tracking ID from config
|
||||
$trackingId = $config['analytics']['tracking_id'] ?? null;
|
||||
|
||||
if ($trackingId) {
|
||||
$vars['analyticsId'] = $trackingId;
|
||||
}
|
||||
|
||||
return $vars;
|
||||
});
|
||||
```
|
||||
|
||||
**custom/config.ini:**
|
||||
|
||||
```ini
|
||||
[analytics]
|
||||
tracking_id = "G-XXXXXXXXXX"
|
||||
|
||||
[plugins]
|
||||
enabled = "languages,analytics"
|
||||
```
|
||||
|
||||
**custom/templates/base.php:**
|
||||
|
||||
```php
|
||||
<head>
|
||||
<!-- ... -->
|
||||
|
||||
<?php if (isset($analyticsId)): ?>
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=<?= htmlspecialchars($analyticsId) ?>"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '<?= htmlspecialchars($analyticsId) ?>');
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</head>
|
||||
```
|
||||
|
||||
### Table of Contents Plugin
|
||||
|
||||
Generate a table of contents from headings.
|
||||
|
||||
**custom/plugins/global/toc.php:**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
if (!isset($vars['content'])) {
|
||||
return $vars;
|
||||
}
|
||||
|
||||
$content = $vars['content'];
|
||||
$toc = [];
|
||||
|
||||
// Extract headings
|
||||
if (preg_match_all('/<h([2-3])>(.*?)<\/h\1>/i', $content, $matches)) {
|
||||
foreach ($matches[0] as $i => $match) {
|
||||
$level = (int)$matches[1][$i];
|
||||
$text = strip_tags($matches[2][$i]);
|
||||
$id = slugify($text);
|
||||
|
||||
// Add ID to heading
|
||||
$newHeading = str_replace('<h' . $level . '>', '<h' . $level . ' id="' . $id . '">', $match);
|
||||
$content = str_replace($match, $newHeading, $content);
|
||||
|
||||
$toc[] = [
|
||||
'level' => $level,
|
||||
'text' => $text,
|
||||
'id' => $id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$vars['content'] = $content;
|
||||
$vars['tableOfContents'] = $toc;
|
||||
|
||||
return $vars;
|
||||
});
|
||||
|
||||
function slugify(string $text): string {
|
||||
$text = strtolower($text);
|
||||
$text = preg_replace('/[^a-z0-9]+/', '-', $text);
|
||||
return trim($text, '-');
|
||||
}
|
||||
```
|
||||
|
||||
**Use in template:**
|
||||
|
||||
```php
|
||||
<?php if (!empty($tableOfContents)): ?>
|
||||
<nav class="toc">
|
||||
<h2>Table of Contents</h2>
|
||||
<ul>
|
||||
<?php foreach ($tableOfContents as $item): ?>
|
||||
<li class="toc-level-<?= $item['level'] ?>">
|
||||
<a href="#<?= htmlspecialchars($item['id']) ?>">
|
||||
<?= htmlspecialchars($item['text']) ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
|
||||
<article>
|
||||
<?= $content ?>
|
||||
</article>
|
||||
```
|
||||
|
||||
### Author Bio Plugin
|
||||
|
||||
Add author information from metadata.
|
||||
|
||||
**custom/plugins/global/author-bio.php:**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
$metadata = $ctx->get('metadata', []);
|
||||
|
||||
// Load author data if specified
|
||||
if (isset($metadata['author'])) {
|
||||
$authorSlug = slugify($metadata['author']);
|
||||
$authorFile = dirname(__DIR__, 2) . "/content/authors/$authorSlug.ini";
|
||||
|
||||
if (file_exists($authorFile)) {
|
||||
$authorData = parse_ini_file($authorFile);
|
||||
$vars['authorBio'] = $authorData;
|
||||
}
|
||||
}
|
||||
|
||||
return $vars;
|
||||
});
|
||||
|
||||
function slugify(string $text): string {
|
||||
return strtolower(preg_replace('/[^a-z0-9]+/', '-', $text));
|
||||
}
|
||||
```
|
||||
|
||||
**content/authors/jane-doe.ini:**
|
||||
|
||||
```ini
|
||||
name = "Jane Doe"
|
||||
bio = "Web developer and writer"
|
||||
email = "jane@example.com"
|
||||
twitter = "@janedoe"
|
||||
website = "https://janedoe.com"
|
||||
```
|
||||
|
||||
**Use in template:**
|
||||
|
||||
```php
|
||||
<?php if (isset($authorBio)): ?>
|
||||
<aside class="author-bio">
|
||||
<h3><?= htmlspecialchars($authorBio['name'] ?? 'Unknown') ?></h3>
|
||||
<p><?= htmlspecialchars($authorBio['bio'] ?? '') ?></p>
|
||||
|
||||
<?php if (isset($authorBio['website'])): ?>
|
||||
<a href="<?= htmlspecialchars($authorBio['website']) ?>">Website</a>
|
||||
<?php endif; ?>
|
||||
</aside>
|
||||
<?php endif; ?>
|
||||
```
|
||||
|
||||
### Related Posts Plugin
|
||||
|
||||
Show related posts based on tags.
|
||||
|
||||
**custom/plugins/global/related-posts.php:**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
$metadata = $ctx->get('metadata', []);
|
||||
|
||||
// Only for pages with tags
|
||||
if (!isset($metadata['tags'])) {
|
||||
return $vars;
|
||||
}
|
||||
|
||||
$currentPath = $ctx->get('currentPath', '');
|
||||
$currentTags = array_map('trim', explode(',', $metadata['tags']));
|
||||
|
||||
// Find other posts with similar tags
|
||||
$contentDir = $ctx->contentDir;
|
||||
$relatedPosts = findRelatedPosts($contentDir, $currentPath, $currentTags);
|
||||
|
||||
if (!empty($relatedPosts)) {
|
||||
$vars['relatedPosts'] = $relatedPosts;
|
||||
}
|
||||
|
||||
return $vars;
|
||||
});
|
||||
|
||||
function findRelatedPosts(string $contentDir, string $currentPath, array $currentTags): array {
|
||||
$posts = [];
|
||||
|
||||
// Recursively scan content directory
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($contentDir, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->getFilename() === 'metadata.ini') {
|
||||
$dir = dirname($file->getPathname());
|
||||
|
||||
// Skip current page
|
||||
if ($dir === $currentPath) continue;
|
||||
|
||||
$metadata = parse_ini_file($file->getPathname());
|
||||
|
||||
if (isset($metadata['tags'])) {
|
||||
$tags = array_map('trim', explode(',', $metadata['tags']));
|
||||
$commonTags = array_intersect($currentTags, $tags);
|
||||
|
||||
if (!empty($commonTags)) {
|
||||
$posts[] = [
|
||||
'title' => $metadata['title'] ?? basename($dir),
|
||||
'url' => str_replace($contentDir, '', $dir) . '/',
|
||||
'tags' => $tags,
|
||||
'relevance' => count($commonTags),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by relevance
|
||||
usort($posts, fn($a, $b) => $b['relevance'] <=> $a['relevance']);
|
||||
|
||||
// Return top 3
|
||||
return array_slice($posts, 0, 3);
|
||||
}
|
||||
```
|
||||
|
||||
**Use in template:**
|
||||
|
||||
```php
|
||||
<?php if (!empty($relatedPosts)): ?>
|
||||
<aside class="related-posts">
|
||||
<h3>Related Posts</h3>
|
||||
<ul>
|
||||
<?php foreach ($relatedPosts as $post): ?>
|
||||
<li>
|
||||
<a href="<?= htmlspecialchars($post['url']) ?>">
|
||||
<?= htmlspecialchars($post['title']) ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</aside>
|
||||
<?php endif; ?>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Return Modified Data
|
||||
|
||||
```php
|
||||
// Good
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
$vars['custom'] = 'value';
|
||||
return $vars; // Always return
|
||||
});
|
||||
|
||||
// Bad
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
$vars['custom'] = 'value';
|
||||
// Missing return - breaks other plugins!
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Use Configuration for Settings
|
||||
|
||||
```php
|
||||
// Good: configurable
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
global $config;
|
||||
|
||||
$wordsPerMinute = $config['reading_time']['words_per_minute'] ?? 200;
|
||||
// Use $wordsPerMinute...
|
||||
|
||||
return $vars;
|
||||
});
|
||||
```
|
||||
|
||||
**custom/config.ini:**
|
||||
|
||||
```ini
|
||||
[reading_time]
|
||||
words_per_minute = 250
|
||||
```
|
||||
|
||||
### 3. Check Variable Existence
|
||||
|
||||
```php
|
||||
// Good: defensive
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
if (isset($vars['content'])) {
|
||||
// Process content
|
||||
}
|
||||
return $vars;
|
||||
});
|
||||
|
||||
// Bad: assumes content exists
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
$wordCount = str_word_count($vars['content']); // May error
|
||||
return $vars;
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Namespace Helper Functions
|
||||
|
||||
```php
|
||||
// Good: prefixed function name
|
||||
function readingTime_calculate(string $content): int {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Bad: generic name (may conflict)
|
||||
function calculate(string $content): int {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Use Type Hints
|
||||
|
||||
```php
|
||||
// Good: type hints for clarity
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx): array {
|
||||
$vars['custom'] = 'value';
|
||||
return $vars;
|
||||
});
|
||||
```
|
||||
|
||||
## Debugging Plugins
|
||||
|
||||
### Check Plugin Loading
|
||||
|
||||
Add debug output to verify your plugin loads:
|
||||
|
||||
```php
|
||||
<?php
|
||||
error_log("My plugin loaded!");
|
||||
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
error_log("TEMPLATE_VARS hook called");
|
||||
error_log("Variables: " . print_r(array_keys($vars), true));
|
||||
|
||||
$vars['debug'] = 'Plugin is working';
|
||||
return $vars;
|
||||
});
|
||||
```
|
||||
|
||||
Check the error log:
|
||||
```bash
|
||||
tail -f /var/log/apache2/error.log
|
||||
```
|
||||
|
||||
### Inspect Hook Order
|
||||
|
||||
Hooks execute in the order they're registered. Check order by logging:
|
||||
|
||||
```php
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
error_log("Hook 1: " . json_encode(array_keys($vars)));
|
||||
return $vars;
|
||||
});
|
||||
|
||||
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
|
||||
error_log("Hook 2: " . json_encode(array_keys($vars)));
|
||||
return $vars;
|
||||
});
|
||||
```
|
||||
|
||||
### Dump Variables in Templates
|
||||
|
||||
Temporarily add to your template:
|
||||
|
||||
```php
|
||||
<pre><?php var_dump($customVariable); ?></pre>
|
||||
```
|
||||
|
||||
**Remove before deploying to production.**
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No inter-plugin communication:** Plugins can't directly call each other
|
||||
- **Single execution order:** Hooks execute in registration order (no priority system)
|
||||
- **Global scope:** Be careful with global variables and function names
|
||||
- **No automatic loading:** Plugins must be listed in `config.ini`
|
||||
|
||||
## What's Next?
|
||||
|
||||
- **[Hook Reference](#)** — Detailed documentation of all hooks
|
||||
- **[Example Plugins](#)** — More real-world plugin examples
|
||||
- **[Contributing](#)** — Share your plugins with the community
|
||||
719
docs/04-development/02-creating-templates.md
Normal file
719
docs/04-development/02-creating-templates.md
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
# Creating Custom Templates
|
||||
|
||||
Templates control the HTML structure and presentation of your content. This guide covers advanced template creation, from simple page layouts to complex list views.
|
||||
|
||||
## Template Hierarchy
|
||||
|
||||
FolderWeb uses a three-level template system:
|
||||
|
||||
1. **Base template** (`base.php`) — The HTML scaffold wrapping everything
|
||||
2. **Content template** — Either `page.php` or a list template
|
||||
3. **Partials** (optional) — Reusable components you create
|
||||
|
||||
```
|
||||
base.php
|
||||
└── page.php or list.php
|
||||
└── Rendered content
|
||||
```
|
||||
|
||||
## Template Resolution
|
||||
|
||||
When rendering a page, FolderWeb looks for templates in this order:
|
||||
|
||||
**For page views:**
|
||||
1. `custom/templates/page.php`
|
||||
2. `app/default/templates/page.php` (fallback)
|
||||
|
||||
**For list views:**
|
||||
1. `custom/templates/{page_template}.php` (e.g., `list-grid.php`)
|
||||
2. `custom/templates/list.php`
|
||||
3. `app/default/templates/{page_template}.php`
|
||||
4. `app/default/templates/list.php` (fallback)
|
||||
|
||||
**For base:**
|
||||
1. `custom/templates/base.php`
|
||||
2. `app/default/templates/base.php` (fallback)
|
||||
|
||||
## Creating a Custom Base Template
|
||||
|
||||
The base template defines the HTML structure for every page.
|
||||
|
||||
### Step 1: Copy the Default
|
||||
|
||||
```bash
|
||||
cp app/default/templates/base.php custom/templates/base.php
|
||||
```
|
||||
|
||||
### Step 2: Customize
|
||||
|
||||
**custom/templates/base.php:**
|
||||
|
||||
```php
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?= htmlspecialchars($currentLang ?? 'en') ?>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle ?? 'My Site') ?></title>
|
||||
|
||||
<?php if (!empty($metaDescription)): ?>
|
||||
<meta name="description" content="<?= htmlspecialchars($metaDescription) ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="<?= htmlspecialchars($pageTitle ?? 'My Site') ?>">
|
||||
<?php if (!empty($metaDescription)): ?>
|
||||
<meta property="og:description" content="<?= htmlspecialchars($metaDescription) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($socialImageUrl)): ?>
|
||||
<meta property="og:image" content="<?= htmlspecialchars($socialImageUrl) ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="/custom/styles/base.css">
|
||||
<?php if (!empty($pageCssUrl)): ?>
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars($pageCssUrl) ?>?v=<?= htmlspecialchars($pageCssHash ?? '') ?>">
|
||||
<?php endif; ?>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main" class="skip-link">Skip to main content</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<a href="<?= htmlspecialchars($langPrefix ?? '') ?>/" class="site-title">
|
||||
My Website
|
||||
</a>
|
||||
|
||||
<nav class="main-nav" aria-label="Main navigation">
|
||||
<ul>
|
||||
<?php foreach ($navigation ?? [] as $item): ?>
|
||||
<li>
|
||||
<a href="<?= htmlspecialchars($item['url']) ?>">
|
||||
<?= htmlspecialchars($item['title']) ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<?php if (!empty($languageUrls) && count($languageUrls) > 1): ?>
|
||||
<nav class="language-switcher" aria-label="Language">
|
||||
<?php foreach ($languageUrls as $lang => $url): ?>
|
||||
<a href="<?= htmlspecialchars($url) ?>"
|
||||
<?= ($lang === ($currentLang ?? 'en')) ? 'aria-current="true"' : '' ?>>
|
||||
<?= htmlspecialchars(strtoupper($lang)) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="site-main">
|
||||
<div class="container">
|
||||
<?= $content ?>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<nav aria-label="Footer navigation">
|
||||
<a href="/privacy/">Privacy</a>
|
||||
<a href="/terms/">Terms</a>
|
||||
<a href="/contact/">Contact</a>
|
||||
</nav>
|
||||
|
||||
<p class="copyright">
|
||||
© <?= 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue