Add breadcrumbs function, docs and tests

This commit is contained in:
Ruben 2026-05-20 23:23:01 +02:00
parent 4448798bf5
commit 2bdb432a9f
12 changed files with 451 additions and 0 deletions

View file

@ -44,6 +44,10 @@ class Context {
get => buildNavigation($this);
}
public array $breadcrumbs {
get => buildBreadcrumbs($this);
}
public string $homeLabel {
get => loadMetadata($this->contentDir)["slug"] ?? "Home";
}

View file

@ -161,6 +161,59 @@ function findPageJs(string $dirPath, string $contentDir): ?array {
];
}
// Build breadcrumbs for the current path.
// Returns empty array for frontpage and level 1-2 paths.
function buildBreadcrumbs(Context $ctx): array {
$requestPath = $ctx->requestPath;
// Empty path = frontpage, no breadcrumbs
if (empty($requestPath)) return [];
$pathParts = explode('/', trim($requestPath, '/'));
// Only show breadcrumbs on level 3+ (e.g. /nyheter/riksrevisjonen/artikkel/)
// Level 1 (/nyheter/) and level 2 (/nyheter/riksrevisjonen/) have no breadcrumbs by default
if (count($pathParts) < 3) return [];
$breadcrumbs = [];
$accumulatedPath = $ctx->contentDir;
$langPrefix = $ctx->get('langPrefix', '');
// Exclude last part (current page)
$maxIndex = count($pathParts) - 2;
foreach ($pathParts as $index => $part) {
// Skip the last part (current page title)
if ($index > $maxIndex) continue;
// Security: skip path traversal attempts
if (str_contains($part, '..')) continue;
$accumulatedPath .= '/' . $part;
// Skip if directory doesn't exist
if (!is_dir($accumulatedPath)) continue;
// Load metadata for this level
$metadata = loadMetadata($accumulatedPath);
$title = $metadata['title'] ?? extractTitle($accumulatedPath) ?? ucfirst($part);
// Get slug for URL (either from metadata or use folder name)
$urlSlug = $metadata['slug'] ?? $part;
// Build URL with trailing slash, use rawurlencode for safety
$url = $langPrefix . '/' . rawurlencode($urlSlug) . '/';
$breadcrumbs[] = [
'title' => $title,
'url' => $url,
'isCurrent' => false
];
}
return $breadcrumbs;
}
function extractMetaDescription(string $dirPath, ?array $metadata): ?string {
// 1. Check for search_description in metadata
if ($metadata && isset($metadata['search_description'])) {

View file

@ -0,0 +1,21 @@
--TEST--
buildBreadcrumbs: returns empty array for empty request path (frontpage)
--FILE--
<?php
require '/var/www/app/context.php';
require '/var/www/app/hooks.php';
require '/var/www/app/constants.php';
require '/var/www/app/helpers.php';
$ctx = new Context(
contentDir: '/tmp/test_content',
templates: new Templates('/tmp/base.php', '/tmp/page.php', '/tmp/list.php'),
requestPath: '',
hasTrailingSlash: false
);
$result = buildBreadcrumbs($ctx);
echo count($result) . "\n";
?>
--EXPECT--
0

View file

@ -0,0 +1,51 @@
--TEST--
buildBreadcrumbs: extracts title from content file when metadata has no title
--FILE--
<?php
require '/var/www/app/context.php';
require '/var/www/app/hooks.php';
require '/var/www/app/constants.php';
require '/var/www/app/helpers.php';
require '/var/www/app/content.php';
// Create temp directory structure with no metadata title
$tempBase = sys_get_temp_dir() . '/phpt_' . getmypid();
$tempContent = $tempBase . '/content';
$tempLevel1 = $tempContent . '/nyheter';
$tempLevel2 = $tempLevel1 . '/riksrevisjonen';
mkdir($tempLevel1, 0777, true);
mkdir($tempLevel2, 0777, true);
// Create content file with h1 title
file_put_contents($tempLevel2 . '/index.md', "# Riksrevisjonen\n\nSome content here.");
// Metadata without title
file_put_contents($tempLevel2 . '/metadata.ini', "slug = riksrevisjonen\n");
$ctx = new Context(
contentDir: $tempContent,
templates: new Templates('/tmp/base.php', '/tmp/page.php', '/tmp/list.php'),
requestPath: 'nyheter/riksrevisjonen/artikkel',
hasTrailingSlash: false
);
$result = buildBreadcrumbs($ctx);
// Output count and titles
echo count($result) . "\n";
echo $result[0]['title'] . "\n";
echo $result[1]['title'] . "\n";
// Cleanup
unlink($tempLevel2 . '/metadata.ini');
unlink($tempLevel2 . '/index.md');
rmdir($tempLevel2);
rmdir($tempLevel1);
rmdir($tempContent);
rmdir($tempBase);
?>
--EXPECT--
2
Nyheter
Riksrevisjonen

View file

@ -0,0 +1,21 @@
--TEST--
buildBreadcrumbs: returns empty array for level 1 path (should not show breadcrumbs)
--FILE--
<?php
require '/var/www/app/context.php';
require '/var/www/app/hooks.php';
require '/var/www/app/constants.php';
require '/var/www/app/helpers.php';
$ctx = new Context(
contentDir: '/tmp/test_content',
templates: new Templates('/tmp/base.php', '/tmp/page.php', '/tmp/list.php'),
requestPath: 'nyheter',
hasTrailingSlash: false
);
$result = buildBreadcrumbs($ctx);
echo count($result) . "\n";
?>
--EXPECT--
0

View file

@ -0,0 +1,21 @@
--TEST--
buildBreadcrumbs: returns empty array for level 2 path (should not show breadcrumbs)
--FILE--
<?php
require '/var/www/app/context.php';
require '/var/www/app/hooks.php';
require '/var/www/app/constants.php';
require '/var/www/app/helpers.php';
$ctx = new Context(
contentDir: '/tmp/test_content',
templates: new Templates('/tmp/base.php', '/tmp/page.php', '/tmp/list.php'),
requestPath: 'nyheter/riksrevisjonen',
hasTrailingSlash: false
);
$result = buildBreadcrumbs($ctx);
echo count($result) . "\n";
?>
--EXPECT--
0

View file

@ -0,0 +1,57 @@
--TEST--
buildBreadcrumbs: builds breadcrumb array for level 3+ path
--FILE--
<?php
require '/var/www/app/context.php';
require '/var/www/app/hooks.php';
require '/var/www/app/constants.php';
require '/var/www/app/helpers.php';
require '/var/www/app/content.php';
// Create temp directory structure
$tempBase = sys_get_temp_dir() . '/phpt_' . getmypid();
$tempContent = $tempBase . '/content';
$tempLevel1 = $tempContent . '/nyheter';
$tempLevel2 = $tempLevel1 . '/riksrevisjonen';
mkdir($tempLevel1, 0777, true);
mkdir($tempLevel2, 0777, true);
// Create metadata files
file_put_contents($tempLevel1 . '/metadata.ini', "title = Nyheter\nslug = nyheter\n");
file_put_contents($tempLevel2 . '/metadata.ini', "title = Riksrevisjonen\nslug = riksrevisjonen\n");
$ctx = new Context(
contentDir: $tempContent,
templates: new Templates('/tmp/base.php', '/tmp/page.php', '/tmp/list.php'),
requestPath: 'nyheter/riksrevisjonen/artikkel',
hasTrailingSlash: false
);
$result = buildBreadcrumbs($ctx);
// Output count and first item details
echo count($result) . "\n";
if (count($result) > 0) {
echo $result[0]['title'] . "\n";
echo $result[0]['url'] . "\n";
}
if (count($result) > 1) {
echo $result[1]['title'] . "\n";
echo $result[1]['url'] . "\n";
}
// Cleanup
unlink($tempLevel2 . '/metadata.ini');
unlink($tempLevel1 . '/metadata.ini');
rmdir($tempLevel2);
rmdir($tempLevel1);
rmdir($tempContent);
rmdir($tempBase);
?>
--EXPECT--
2
Nyheter
/nyheter/
Riksrevisjonen
/riksrevisjonen/

View file

@ -0,0 +1,55 @@
--TEST--
buildBreadcrumbs: skips path traversal attempts (security)
--FILE--
<?php
require '/var/www/app/context.php';
require '/var/www/app/hooks.php';
require '/var/www/app/constants.php';
require '/var/www/app/helpers.php';
require '/var/www/app/content.php';
// Create temp directory structure
$tempBase = sys_get_temp_dir() . '/phpt_' . getmypid();
$tempContent = $tempBase . '/content';
$tempLevel1 = $tempContent . '/nyheter';
$tempLevel2 = $tempLevel1 . '/riksrevisjonen';
mkdir($tempLevel1, 0777, true);
mkdir($tempLevel2, 0777, true);
// Create metadata files
file_put_contents($tempLevel1 . '/metadata.ini', "title = Nyheter\n");
file_put_contents($tempLevel2 . '/metadata.ini', "title = Riksrevisjonen\n");
// Path with ".." should be skipped - the .. segment is ignored but valid dirs before it are included
$ctx = new Context(
contentDir: $tempContent,
templates: new Templates('/tmp/base.php', '/tmp/page.php', '/tmp/list.php'),
requestPath: 'nyheter/riksrevisjonen/../test',
hasTrailingSlash: false
);
$result = buildBreadcrumbs($ctx);
// Output count - nyheter and riksrevisjonen exist, .. is skipped, test doesn't exist
echo count($result) . "\n";
// Verify no ".." appears in any URL
$hasTraversal = false;
foreach ($result as $crumb) {
if (str_contains($crumb['url'], '..')) {
$hasTraversal = true;
}
}
echo ($hasTraversal ? "traversal" : "safe") . "\n";
// Cleanup
unlink($tempLevel2 . '/metadata.ini');
unlink($tempLevel1 . '/metadata.ini');
rmdir($tempLevel2);
rmdir($tempLevel1);
rmdir($tempContent);
rmdir($tempBase);
?>
--EXPECT--
2
safe

View file

@ -0,0 +1,39 @@
--TEST--
buildBreadcrumbs: skips non-existent directories gracefully
--FILE--
<?php
require '/var/www/app/context.php';
require '/var/www/app/hooks.php';
require '/var/www/app/constants.php';
require '/var/www/app/helpers.php';
require '/var/www/app/content.php';
// Create temp directory structure with only level 1
$tempBase = sys_get_temp_dir() . '/phpt_' . getmypid();
$tempContent = $tempBase . '/content';
$tempLevel1 = $tempContent . '/nyheter';
mkdir($tempLevel1, 0777, true);
file_put_contents($tempLevel1 . '/metadata.ini', "title = Nyheter\n");
// Request path has middle directories that don't exist
$ctx = new Context(
contentDir: $tempContent,
templates: new Templates('/tmp/base.php', '/tmp/page.php', '/tmp/list.php'),
requestPath: 'nyheter/nonexistent/artikkel',
hasTrailingSlash: false
);
$result = buildBreadcrumbs($ctx);
// Only nyheter exists, so only 1 breadcrumb
echo count($result) . "\n";
// Cleanup
unlink($tempLevel1 . '/metadata.ini');
rmdir($tempLevel1);
rmdir($tempContent);
rmdir($tempBase);
?>
--EXPECT--
1

View file

@ -0,0 +1,49 @@
--TEST--
buildBreadcrumbs: uses slug from metadata for URL when available
--FILE--
<?php
require '/var/www/app/context.php';
require '/var/www/app/hooks.php';
require '/var/www/app/constants.php';
require '/var/www/app/helpers.php';
require '/var/www/app/content.php';
// Create temp directory structure with slug override
$tempBase = sys_get_temp_dir() . '/phpt_' . getmypid();
$tempContent = $tempBase . '/content';
$tempLevel1 = $tempContent . '/nyheter';
$tempLevel2 = $tempLevel1 . '/riksrevisjonen';
mkdir($tempLevel1, 0777, true);
mkdir($tempLevel2, 0777, true);
// Metadata with custom slug
file_put_contents($tempLevel1 . '/metadata.ini', "title = Nyheter\nslug = nyheter-custom\n");
file_put_contents($tempLevel2 . '/metadata.ini', "title = Riksrevisjonen\nslug = statens-revisor\n");
$ctx = new Context(
contentDir: $tempContent,
templates: new Templates('/tmp/base.php', '/tmp/page.php', '/tmp/list.php'),
requestPath: 'nyheter/riksrevisjonen/artikkel',
hasTrailingSlash: false
);
$result = buildBreadcrumbs($ctx);
// Output count and URLs
echo count($result) . "\n";
echo $result[0]['url'] . "\n";
echo $result[1]['url'] . "\n";
// Cleanup
unlink($tempLevel2 . '/metadata.ini');
unlink($tempLevel1 . '/metadata.ini');
rmdir($tempLevel2);
rmdir($tempLevel1);
rmdir($tempContent);
rmdir($tempBase);
?>
--EXPECT--
2
/nyheter-custom/
/statens-revisor/

View file

@ -22,6 +22,7 @@ These use PHP 8.4 `private(set)` — readable but not writable from outside the
| Property | Type | Description |
|---|---|---|
| `navigation` | array | `buildNavigation($this)` — lazy-computed on access |
| `breadcrumbs` | array | `buildBreadcrumbs($this)` — lazy-computed on access, returns breadcrumb trail for nested pages |
| `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

View file

@ -0,0 +1,79 @@
# Breadcrumbs
**`buildBreadcrumbs(Context $ctx): array`**
Returns a breadcrumb trail for nested content directories.
| Condition | Return |
|---|---|
| Empty `requestPath` (frontpage) | `[]` |
| Level 1 path (e.g., `/nyheter/`) | `[]` |
| Level 2 path (e.g., `/nyheter/riksrevisjonen/`) | `[]` |
| Level 3+ (e.g., `/nyheter/riksrevisjonen/artikkel/`) | Array of breadcrumb items |
### Breadcrumb Item Structure
```php
[
'title' => string, // Display title
'url' => string, // URL with trailing slash
'isCurrent' => false // Always false (current page excluded)
]
```
### Title Resolution
1. `title` field from `metadata.ini`
2. First `<h1>` in content files (`.md`, `.html`, `.php`)
3. `ucfirst(folder_name)` as fallback
### URL Construction
Uses `slug` from metadata if available, otherwise the folder name. URLs are:
- Encoded with `rawurlencode()` for safety
- Always include trailing slash
- Prefixed with language prefix (from `$ctx->get('langPrefix', '')`)
### Security
- Path segments containing `..` are skipped (path traversal protection)
- Only existing directories are included
- URLs are encoded to prevent injection
## Usage in Templates
```php
<?php if (!empty($breadcrumbs)): ?>
<nav aria-label="Breadcrumb">
<ol>
<?php foreach ($breadcrumbs as $crumb): ?>
<li>
<a href="<?= htmlspecialchars($crumb['url']) ?>">
<?= htmlspecialchars($crumb['title']) ?>
</a>
</li>
<?php endforeach; ?>
<li aria-current="page"><?= htmlspecialchars($pageTitle ?? '') ?></li>
</ol>
</nav>
<?php endif; ?>
```
## Tests
Located in `devel/tests/helpers/`:
- `build_breadcrumbs_empty_path.phpt` — frontpage returns empty array
- `build_breadcrumbs_level1.phpt` — single level returns empty array
- `build_breadcrumbs_level2.phpt` — two levels returns empty array
- `build_breadcrumbs_level3.phpt` — three+ levels returns breadcrumb array
- `build_breadcrumbs_security_traversal.phpt` — path traversal attempts are skipped
- `build_breadcrumbs_skips_missing_dirs.phpt` — missing directories are handled gracefully
- `build_breadcrumbs_slug_override.phpt` — metadata slug overrides folder name
- `build_breadcrumbs_extract_title_from_content.phpt` — title extracted from content files
Run tests:
```bash
cd devel && ./tests/run.sh helpers
```