Add breadcrumbs function, docs and tests
This commit is contained in:
parent
4448798bf5
commit
2bdb432a9f
12 changed files with 451 additions and 0 deletions
|
|
@ -44,6 +44,10 @@ class Context {
|
||||||
get => buildNavigation($this);
|
get => buildNavigation($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public array $breadcrumbs {
|
||||||
|
get => buildBreadcrumbs($this);
|
||||||
|
}
|
||||||
|
|
||||||
public string $homeLabel {
|
public string $homeLabel {
|
||||||
get => loadMetadata($this->contentDir)["slug"] ?? "Home";
|
get => loadMetadata($this->contentDir)["slug"] ?? "Home";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
function extractMetaDescription(string $dirPath, ?array $metadata): ?string {
|
||||||
// 1. Check for search_description in metadata
|
// 1. Check for search_description in metadata
|
||||||
if ($metadata && isset($metadata['search_description'])) {
|
if ($metadata && isset($metadata['search_description'])) {
|
||||||
|
|
|
||||||
21
devel/tests/helpers/build_breadcrumbs_empty_path.phpt
Normal file
21
devel/tests/helpers/build_breadcrumbs_empty_path.phpt
Normal 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
|
||||||
|
|
@ -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
|
||||||
21
devel/tests/helpers/build_breadcrumbs_level1.phpt
Normal file
21
devel/tests/helpers/build_breadcrumbs_level1.phpt
Normal 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
|
||||||
21
devel/tests/helpers/build_breadcrumbs_level2.phpt
Normal file
21
devel/tests/helpers/build_breadcrumbs_level2.phpt
Normal 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
|
||||||
57
devel/tests/helpers/build_breadcrumbs_level3.phpt
Normal file
57
devel/tests/helpers/build_breadcrumbs_level3.phpt
Normal 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/
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
49
devel/tests/helpers/build_breadcrumbs_slug_override.phpt
Normal file
49
devel/tests/helpers/build_breadcrumbs_slug_override.phpt
Normal 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/
|
||||||
|
|
@ -22,6 +22,7 @@ These use PHP 8.4 `private(set)` — readable but not writable from outside the
|
||||||
| Property | Type | Description |
|
| Property | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `navigation` | array | `buildNavigation($this)` — lazy-computed on access |
|
| `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" |
|
| `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
|
### Plugin Data Store
|
||||||
|
|
|
||||||
79
docs/04-development/09-breadcrumbs.md
Normal file
79
docs/04-development/09-breadcrumbs.md
Normal 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
|
||||||
|
```
|
||||||
Loading…
Add table
Add a link
Reference in a new issue