folderweb/docs/04-development/02-creating-templates.md

720 lines
17 KiB
Markdown
Raw Normal View History

# Creating Custom Templates
Templates control the HTML structure and presentation of your content. This guide covers advanced template creation, from simple page layouts to complex list views.
## Template Hierarchy
FolderWeb uses a three-level template system:
1. **Base template** (`base.php`) — The HTML scaffold wrapping everything
2. **Content template** — Either `page.php` or a list template
3. **Partials** (optional) — Reusable components you create
```
base.php
└── page.php or list.php
└── Rendered content
```
## Template Resolution
When rendering a page, FolderWeb looks for templates in this order:
**For page views:**
1. `custom/templates/page.php`
2. `app/default/templates/page.php` (fallback)
**For list views:**
1. `custom/templates/{page_template}.php` (e.g., `list-grid.php`)
2. `custom/templates/list.php`
3. `app/default/templates/{page_template}.php`
4. `app/default/templates/list.php` (fallback)
**For base:**
1. `custom/templates/base.php`
2. `app/default/templates/base.php` (fallback)
## Creating a Custom Base Template
The base template defines the HTML structure for every page.
### Step 1: Copy the Default
```bash
cp app/default/templates/base.php custom/templates/base.php
```
### Step 2: Customize
**custom/templates/base.php:**
```php
<!DOCTYPE html>
<html lang="<?= htmlspecialchars($currentLang ?? 'en') ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle ?? 'My Site') ?></title>
<?php if (!empty($metaDescription)): ?>
<meta name="description" content="<?= htmlspecialchars($metaDescription) ?>">
<?php endif; ?>
<!-- Open Graph -->
<meta property="og:title" content="<?= htmlspecialchars($pageTitle ?? 'My Site') ?>">
<?php if (!empty($metaDescription)): ?>
<meta property="og:description" content="<?= htmlspecialchars($metaDescription) ?>">
<?php endif; ?>
<?php if (!empty($socialImageUrl)): ?>
<meta property="og:image" content="<?= htmlspecialchars($socialImageUrl) ?>">
<?php endif; ?>
<!-- Styles -->
<link rel="stylesheet" href="/custom/styles/base.css">
<?php if (!empty($pageCssUrl)): ?>
<link rel="stylesheet" href="<?= htmlspecialchars($pageCssUrl) ?>?v=<?= htmlspecialchars($pageCssHash ?? '') ?>">
<?php endif; ?>
</head>
<body>
<a href="#main" class="skip-link">Skip to main content</a>
<header class="site-header">
<div class="container">
<a href="<?= htmlspecialchars($langPrefix ?? '') ?>/" class="site-title">
My Website
</a>
<nav class="main-nav" aria-label="Main navigation">
<ul>
<?php foreach ($navigation ?? [] as $item): ?>
<li>
<a href="<?= htmlspecialchars($item['url']) ?>">
<?= htmlspecialchars($item['title']) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</nav>
<?php if (!empty($languageUrls) && count($languageUrls) > 1): ?>
<nav class="language-switcher" aria-label="Language">
<?php foreach ($languageUrls as $lang => $url): ?>
<a href="<?= htmlspecialchars($url) ?>"
<?= ($lang === ($currentLang ?? 'en')) ? 'aria-current="true"' : '' ?>>
<?= htmlspecialchars(strtoupper($lang)) ?>
</a>
<?php endforeach; ?>
</nav>
<?php endif; ?>
</div>
</header>
<main id="main" class="site-main">
<div class="container">
<?= $content ?>
</div>
</main>
<footer class="site-footer">
<div class="container">
<nav aria-label="Footer navigation">
<a href="/privacy/">Privacy</a>
<a href="/terms/">Terms</a>
<a href="/contact/">Contact</a>
</nav>
<p class="copyright">
&copy; <?= date('Y') ?> My Website
</p>
<p class="performance">
<?= htmlspecialchars($translations['footer_handcoded'] ?? 'Generated in') ?>
<?= number_format((microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']) * 1000, 2) ?>
<?= htmlspecialchars($translations['footer_page_time'] ?? 'ms') ?>
</p>
</div>
</footer>
</body>
</html>
```
### Key Features
- **Skip link** for accessibility
- **Container divs** for layout control
- **Semantic HTML** (header, nav, main, footer)
- **ARIA labels** for screen readers
- **Open Graph tags** for social media
- **Performance metrics** in footer
## Creating Custom Page Templates
Page templates wrap single-page content.
### Blog Post Template
**custom/templates/page.php:**
```php
<article class="blog-post">
<header class="post-header">
<?php if (isset($metadata['title'])): ?>
<h1><?= htmlspecialchars($metadata['title']) ?></h1>
<?php endif; ?>
<div class="post-meta">
<?php if (isset($metadata['date']) && ($metadata['show_date'] ?? true)): ?>
<time datetime="<?= $metadata['date'] ?>">
<?= $metadata['formatted_date'] ?? $metadata['date'] ?>
</time>
<?php endif; ?>
<?php if (isset($metadata['author'])): ?>
<span class="author">
by <?= htmlspecialchars($metadata['author']) ?>
</span>
<?php endif; ?>
<?php if (isset($readingTime)): ?>
<span class="reading-time">
<?= $readingTime ?> min read
</span>
<?php endif; ?>
</div>
<?php if (isset($metadata['tags'])): ?>
<div class="post-tags">
<?php foreach (explode(',', $metadata['tags']) as $tag): ?>
<span class="tag"><?= htmlspecialchars(trim($tag)) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</header>
<div class="post-content">
<?= $content ?>
</div>
<?php if (!empty($relatedPosts)): ?>
<aside class="related-posts">
<h2>Related Posts</h2>
<ul>
<?php foreach ($relatedPosts as $post): ?>
<li>
<a href="<?= htmlspecialchars($post['url']) ?>">
<?= htmlspecialchars($post['title']) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</aside>
<?php endif; ?>
</article>
```
### Portfolio Item Template
**custom/templates/page-portfolio.php:**
```php
<article class="portfolio-item">
<?php if (isset($metadata['cover_image'])): ?>
<div class="project-hero">
<img src="<?= $metadata['cover_image'] ?>"
alt="<?= htmlspecialchars($metadata['title'] ?? '') ?>">
</div>
<?php endif; ?>
<header class="project-header">
<h1><?= htmlspecialchars($metadata['title'] ?? 'Untitled') ?></h1>
<dl class="project-details">
<?php if (isset($metadata['client'])): ?>
<dt>Client</dt>
<dd><?= htmlspecialchars($metadata['client']) ?></dd>
<?php endif; ?>
<?php if (isset($metadata['year'])): ?>
<dt>Year</dt>
<dd><?= htmlspecialchars($metadata['year']) ?></dd>
<?php endif; ?>
<?php if (isset($metadata['role'])): ?>
<dt>Role</dt>
<dd><?= htmlspecialchars($metadata['role']) ?></dd>
<?php endif; ?>
</dl>
</header>
<div class="project-content">
<?= $content ?>
</div>
<?php if (isset($metadata['project_url'])): ?>
<footer class="project-footer">
<a href="<?= htmlspecialchars($metadata['project_url']) ?>"
class="button" target="_blank" rel="noopener">
View Live Project →
</a>
</footer>
<?php endif; ?>
</article>
```
**To use:** Set in metadata:
```ini
[settings]
page_template = "page-portfolio"
```
Wait, that won't work for page templates—only list templates use `page_template`. For page templates, you'd need to select via a plugin or use different template files per directory. Let's stick with one `page.php` that adapts based on metadata.
## Creating Custom List Templates
List templates display collections of items.
### Card Grid Layout
**custom/templates/list-cards.php:**
```php
<?php if ($pageContent): ?>
<div class="list-intro">
<?= $pageContent ?>
</div>
<?php endif; ?>
<div class="card-grid">
<?php foreach ($items as $item): ?>
<article class="card">
<?php if (isset($item['cover_image'])): ?>
<a href="<?= $item['url'] ?>" class="card-image">
<img src="<?= $item['cover_image'] ?>"
alt="<?= htmlspecialchars($item['title']) ?>"
loading="lazy">
</a>
<?php endif; ?>
<div class="card-content">
<h2 class="card-title">
<a href="<?= $item['url'] ?>">
<?= htmlspecialchars($item['title']) ?>
</a>
</h2>
<?php if (isset($item['date'])): ?>
<time class="card-date" datetime="<?= $item['date'] ?>">
<?= $item['formatted_date'] ?? $item['date'] ?>
</time>
<?php endif; ?>
<?php if (isset($item['summary'])): ?>
<p class="card-summary">
<?= htmlspecialchars($item['summary']) ?>
</p>
<?php endif; ?>
<a href="<?= $item['url'] ?>" class="card-link">
Read more →
</a>
</div>
</article>
<?php endforeach; ?>
</div>
```
**Corresponding CSS:**
```css
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
gap: 2rem;
margin-top: 2rem;
}
.card {
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 2px 8px oklch(0% 0 0 / 0.1);
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px oklch(0% 0 0 / 0.15);
}
}
.card-image img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.card-content {
padding: 1.5rem;
}
.card-title {
margin: 0 0 0.5rem;
font-size: 1.25rem;
& a {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.card-date {
display: block;
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: 0.75rem;
}
.card-summary {
margin: 0 0 1rem;
line-height: 1.6;
}
.card-link {
font-weight: 500;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
```
### Timeline Layout
**custom/templates/list-timeline.php:**
```php
<?= $pageContent ?>
<div class="timeline">
<?php
$currentYear = null;
foreach ($items as $item):
// Extract year from date
$year = isset($item['date']) ? date('Y', strtotime($item['date'])) : null;
// Show year marker when year changes
if ($year && $year !== $currentYear):
$currentYear = $year;
?>
<div class="timeline-year">
<h2><?= $year ?></h2>
</div>
<?php endif; ?>
<article class="timeline-item">
<time class="timeline-date">
<?= $item['formatted_date'] ?? ($item['date'] ?? '') ?>
</time>
<div class="timeline-content">
<h3>
<a href="<?= $item['url'] ?>">
<?= htmlspecialchars($item['title']) ?>
</a>
</h3>
<?php if (isset($item['summary'])): ?>
<p><?= htmlspecialchars($item['summary']) ?></p>
<?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</div>
```
**CSS:**
```css
.timeline {
position: relative;
max-width: 800px;
margin: 2rem 0;
padding-left: 2rem;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: var(--color-border);
}
}
.timeline-year {
margin: 2rem 0 1rem;
& h2 {
font-size: 1.5rem;
color: var(--color-accent);
}
}
.timeline-item {
position: relative;
margin-bottom: 2rem;
&::before {
content: '';
position: absolute;
left: -2.5rem;
top: 0.5rem;
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
background: var(--color-accent);
border: 2px solid var(--color-bg);
}
}
.timeline-date {
display: block;
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: 0.25rem;
}
.timeline-content {
& h3 {
margin: 0 0 0.5rem;
font-size: 1.125rem;
}
& p {
margin: 0;
line-height: 1.6;
}
}
```
### Magazine Layout
**custom/templates/list-magazine.php:**
```php
<?= $pageContent ?>
<?php if (!empty($items)): ?>
<div class="magazine-layout">
<!-- Featured post (first item) -->
<?php $featured = array_shift($items); ?>
<article class="magazine-featured">
<?php if (isset($featured['cover_image'])): ?>
<a href="<?= $featured['url'] ?>" class="featured-image">
<img src="<?= $featured['cover_image'] ?>"
alt="<?= htmlspecialchars($featured['title']) ?>">
</a>
<?php endif; ?>
<div class="featured-content">
<h2>
<a href="<?= $featured['url'] ?>">
<?= htmlspecialchars($featured['title']) ?>
</a>
</h2>
<?php if (isset($featured['summary'])): ?>
<p class="featured-summary">
<?= htmlspecialchars($featured['summary']) ?>
</p>
<?php endif; ?>
<a href="<?= $featured['url'] ?>" class="read-more">
Read article →
</a>
</div>
</article>
<!-- Remaining posts in grid -->
<?php if (!empty($items)): ?>
<div class="magazine-grid">
<?php foreach ($items as $item): ?>
<article class="magazine-item">
<?php if (isset($item['cover_image'])): ?>
<a href="<?= $item['url'] ?>">
<img src="<?= $item['cover_image'] ?>"
alt="<?= htmlspecialchars($item['title']) ?>"
loading="lazy">
</a>
<?php endif; ?>
<h3>
<a href="<?= $item['url'] ?>">
<?= htmlspecialchars($item['title']) ?>
</a>
</h3>
<?php if (isset($item['date'])): ?>
<time datetime="<?= $item['date'] ?>">
<?= $item['formatted_date'] ?? $item['date'] ?>
</time>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
```
## Using Partials (Template Includes)
Break complex templates into reusable components.
### Creating a Partial
**custom/templates/partials/post-card.php:**
```php
<article class="post-card">
<?php if (isset($post['cover_image'])): ?>
<a href="<?= $post['url'] ?>">
<img src="<?= $post['cover_image'] ?>"
alt="<?= htmlspecialchars($post['title']) ?>">
</a>
<?php endif; ?>
<h3>
<a href="<?= $post['url'] ?>">
<?= htmlspecialchars($post['title']) ?>
</a>
</h3>
<?php if (isset($post['summary'])): ?>
<p><?= htmlspecialchars($post['summary']) ?></p>
<?php endif; ?>
</article>
```
### Using a Partial
**custom/templates/list.php:**
```php
<?= $pageContent ?>
<div class="post-list">
<?php foreach ($items as $post): ?>
<?php include __DIR__ . '/partials/post-card.php'; ?>
<?php endforeach; ?>
</div>
```
**Note:** Set `$post` before including, as the partial expects it.
## Conditional Templates
Use metadata to vary presentation.
```php
<?php if (isset($metadata['layout']) && $metadata['layout'] === 'wide'): ?>
<article class="wide-layout">
<?= $content ?>
</article>
<?php else: ?>
<article class="standard-layout">
<div class="container">
<?= $content ?>
</div>
</article>
<?php endif; ?>
```
**Set in metadata:**
```ini
[settings]
layout = "wide"
```
## Template Best Practices
### 1. Always Escape Output
```php
<!-- Good -->
<h1><?= htmlspecialchars($title) ?></h1>
<!-- Bad -->
<h1><?= $title ?></h1>
```
### 2. Check Before Using
```php
<!-- Good -->
<?php if (isset($metadata['author'])): ?>
<p>By <?= htmlspecialchars($metadata['author']) ?></p>
<?php endif; ?>
<!-- Bad -->
<p>By <?= htmlspecialchars($metadata['author']) ?></p>
```
### 3. Use Semantic HTML
```php
<!-- Good -->
<article>
<header><h1>Title</h1></header>
<div class="content">Content</div>
<footer>Meta</footer>
</article>
<!-- Bad -->
<div class="post">
<div class="title">Title</div>
<div class="content">Content</div>
</div>
```
### 4. Add ARIA Labels
```php
<nav aria-label="Main navigation">
<!-- navigation items -->
</nav>
<nav aria-label="Language">
<!-- language switcher -->
</nav>
```
### 5. Keep Logic Minimal
```php
<!-- Good: simple check -->
<?php if (isset($item['date'])): ?>
<time><?= $item['formatted_date'] ?></time>
<?php endif; ?>
<!-- Bad: complex logic (move to plugin) -->
<?php
$recentPosts = array_filter($items, fn($item) =>
strtotime($item['date']) > strtotime('-30 days')
);
usort($recentPosts, fn($a, $b) => strcmp($b['date'], $a['date']));
?>
```
## What's Next?
- **[Template Variables Reference](#)** — See all available variables
- **[Plugin System](#)** — Add custom variables to templates
- **[Styling Guide](#)** — Style your custom templates