From 2bdb432a9fe69f52d821a63019c43c25bb3744c6 Mon Sep 17 00:00:00 2001 From: Ruben Date: Wed, 20 May 2026 23:23:01 +0200 Subject: [PATCH] Add breadcrumbs function, docs and tests --- app/context.php | 4 + app/helpers.php | 53 +++++++++++++ .../helpers/build_breadcrumbs_empty_path.phpt | 21 +++++ ...readcrumbs_extract_title_from_content.phpt | 51 ++++++++++++ .../helpers/build_breadcrumbs_level1.phpt | 21 +++++ .../helpers/build_breadcrumbs_level2.phpt | 21 +++++ .../helpers/build_breadcrumbs_level3.phpt | 57 +++++++++++++ .../build_breadcrumbs_security_traversal.phpt | 55 +++++++++++++ .../build_breadcrumbs_skips_missing_dirs.phpt | 39 +++++++++ .../build_breadcrumbs_slug_override.phpt | 49 ++++++++++++ docs/04-development/04-context-api.md | 1 + docs/04-development/09-breadcrumbs.md | 79 +++++++++++++++++++ 12 files changed, 451 insertions(+) create mode 100644 devel/tests/helpers/build_breadcrumbs_empty_path.phpt create mode 100644 devel/tests/helpers/build_breadcrumbs_extract_title_from_content.phpt create mode 100644 devel/tests/helpers/build_breadcrumbs_level1.phpt create mode 100644 devel/tests/helpers/build_breadcrumbs_level2.phpt create mode 100644 devel/tests/helpers/build_breadcrumbs_level3.phpt create mode 100644 devel/tests/helpers/build_breadcrumbs_security_traversal.phpt create mode 100644 devel/tests/helpers/build_breadcrumbs_skips_missing_dirs.phpt create mode 100644 devel/tests/helpers/build_breadcrumbs_slug_override.phpt create mode 100644 docs/04-development/09-breadcrumbs.md diff --git a/app/context.php b/app/context.php index d9c3a3a..0f82d0b 100644 --- a/app/context.php +++ b/app/context.php @@ -44,6 +44,10 @@ class Context { get => buildNavigation($this); } + public array $breadcrumbs { + get => buildBreadcrumbs($this); + } + public string $homeLabel { get => loadMetadata($this->contentDir)["slug"] ?? "Home"; } diff --git a/app/helpers.php b/app/helpers.php index 27d6e0c..6bc1406 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -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'])) { diff --git a/devel/tests/helpers/build_breadcrumbs_empty_path.phpt b/devel/tests/helpers/build_breadcrumbs_empty_path.phpt new file mode 100644 index 0000000..b02a591 --- /dev/null +++ b/devel/tests/helpers/build_breadcrumbs_empty_path.phpt @@ -0,0 +1,21 @@ +--TEST-- +buildBreadcrumbs: returns empty array for empty request path (frontpage) +--FILE-- + +--EXPECT-- +0 diff --git a/devel/tests/helpers/build_breadcrumbs_extract_title_from_content.phpt b/devel/tests/helpers/build_breadcrumbs_extract_title_from_content.phpt new file mode 100644 index 0000000..9b1e222 --- /dev/null +++ b/devel/tests/helpers/build_breadcrumbs_extract_title_from_content.phpt @@ -0,0 +1,51 @@ +--TEST-- +buildBreadcrumbs: extracts title from content file when metadata has no title +--FILE-- + +--EXPECT-- +2 +Nyheter +Riksrevisjonen diff --git a/devel/tests/helpers/build_breadcrumbs_level1.phpt b/devel/tests/helpers/build_breadcrumbs_level1.phpt new file mode 100644 index 0000000..ed16c40 --- /dev/null +++ b/devel/tests/helpers/build_breadcrumbs_level1.phpt @@ -0,0 +1,21 @@ +--TEST-- +buildBreadcrumbs: returns empty array for level 1 path (should not show breadcrumbs) +--FILE-- + +--EXPECT-- +0 diff --git a/devel/tests/helpers/build_breadcrumbs_level2.phpt b/devel/tests/helpers/build_breadcrumbs_level2.phpt new file mode 100644 index 0000000..9be36ac --- /dev/null +++ b/devel/tests/helpers/build_breadcrumbs_level2.phpt @@ -0,0 +1,21 @@ +--TEST-- +buildBreadcrumbs: returns empty array for level 2 path (should not show breadcrumbs) +--FILE-- + +--EXPECT-- +0 diff --git a/devel/tests/helpers/build_breadcrumbs_level3.phpt b/devel/tests/helpers/build_breadcrumbs_level3.phpt new file mode 100644 index 0000000..3787511 --- /dev/null +++ b/devel/tests/helpers/build_breadcrumbs_level3.phpt @@ -0,0 +1,57 @@ +--TEST-- +buildBreadcrumbs: builds breadcrumb array for level 3+ path +--FILE-- + 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/ diff --git a/devel/tests/helpers/build_breadcrumbs_security_traversal.phpt b/devel/tests/helpers/build_breadcrumbs_security_traversal.phpt new file mode 100644 index 0000000..957cae1 --- /dev/null +++ b/devel/tests/helpers/build_breadcrumbs_security_traversal.phpt @@ -0,0 +1,55 @@ +--TEST-- +buildBreadcrumbs: skips path traversal attempts (security) +--FILE-- + +--EXPECT-- +2 +safe diff --git a/devel/tests/helpers/build_breadcrumbs_skips_missing_dirs.phpt b/devel/tests/helpers/build_breadcrumbs_skips_missing_dirs.phpt new file mode 100644 index 0000000..c85b9d6 --- /dev/null +++ b/devel/tests/helpers/build_breadcrumbs_skips_missing_dirs.phpt @@ -0,0 +1,39 @@ +--TEST-- +buildBreadcrumbs: skips non-existent directories gracefully +--FILE-- + +--EXPECT-- +1 diff --git a/devel/tests/helpers/build_breadcrumbs_slug_override.phpt b/devel/tests/helpers/build_breadcrumbs_slug_override.phpt new file mode 100644 index 0000000..75e0fdb --- /dev/null +++ b/devel/tests/helpers/build_breadcrumbs_slug_override.phpt @@ -0,0 +1,49 @@ +--TEST-- +buildBreadcrumbs: uses slug from metadata for URL when available +--FILE-- + +--EXPECT-- +2 +/nyheter-custom/ +/statens-revisor/ diff --git a/docs/04-development/04-context-api.md b/docs/04-development/04-context-api.md index 54cf1fd..9eb9949 100644 --- a/docs/04-development/04-context-api.md +++ b/docs/04-development/04-context-api.md @@ -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 diff --git a/docs/04-development/09-breadcrumbs.md b/docs/04-development/09-breadcrumbs.md new file mode 100644 index 0000000..cef8e45 --- /dev/null +++ b/docs/04-development/09-breadcrumbs.md @@ -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 `

` 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 + + + +``` + +## 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 +```