folderweb/docs/04-development/02-creating-templates.md
Ruben 76697e4656 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
2025-11-27 23:01:02 +01:00

17 KiB

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

cp app/default/templates/base.php custom/templates/base.php

Step 2: Customize

custom/templates/base.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:

<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:

<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:

[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 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:

.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:

<?= $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:

.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:

<?= $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:

<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:

<?= $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 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:

[settings]
layout = "wide"

Template Best Practices

1. Always Escape Output

<!-- Good -->
<h1><?= htmlspecialchars($title) ?></h1>

<!-- Bad -->
<h1><?= $title ?></h1>

2. Check Before Using

<!-- 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

<!-- 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

<nav aria-label="Main navigation">
  <!-- navigation items -->
</nav>

<nav aria-label="Language">
  <!-- language switcher -->
</nav>

5. Keep Logic Minimal

<!-- 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?