From 22b767acd3b90ee627eb2e1222038c84e4a4ba7c Mon Sep 17 00:00:00 2001 From: Chase Miller Date: Thu, 30 Apr 2026 20:02:53 -0600 Subject: [PATCH 1/2] Restore bin/docs-lint and bin/install-hooks The .github/workflows/docs-lint.yaml job runs `bin/docs-lint` on every PR touching docs/, but the script wasn't tracked -- it lived in a stash. Restoring it. The script enforces the docs-site rules: 1. No legacy /docs/... links, no ./ or ../ relative links 2. Filenames match ^[a-z0-9][a-z0-9\-]*\.md$ (except _index.md) 3. Every directory under docs/ has _index.md 4. Every .md file starts with YAML frontmatter 5. Every page has a title; non-root pages have a weight 6. Every /documentation/{pkg}/{ver}/{slug} link resolves on disk Also fixed two relative ../advanced/livewire-widgets.md links in api-reference/widget-contract.md (introduced in the dual-render docs commit) -- replaced with absolute /documentation/layup/v1/... links to match the established convention. Restored bin/install-hooks alongside since it installs the pre-commit hook that runs bin/docs-lint locally for the same trigger condition the CI workflow uses. bin/docs-lint passes: 32 files clean. --- bin/docs-lint | 213 ++++++++++++++++++++++++++ bin/install-hooks | 30 ++++ docs/api-reference/widget-contract.md | 4 +- 3 files changed, 245 insertions(+), 2 deletions(-) create mode 100755 bin/docs-lint create mode 100755 bin/install-hooks diff --git a/bin/docs-lint b/bin/docs-lint new file mode 100755 index 0000000..ea7887a --- /dev/null +++ b/bin/docs-lint @@ -0,0 +1,213 @@ +#!/usr/bin/env php +isDir()) { + if (! is_file($file->getPathname() . '/_index.md')) { + $errors[] = relPath($docsRoot, $file->getPathname()) . ': directory missing _index.md'; + } + continue; + } + + if ($file->getExtension() !== 'md') { + continue; + } + + $name = $file->getFilename(); + + if ($name !== '_index.md' && ! preg_match($filenameRegex, $name)) { + $errors[] = relPath($docsRoot, $file->getPathname()) . ": filename must match {$filenameRegex}"; + } + + $mdFiles[] = $file->getPathname(); +} + +foreach ($mdFiles as $path) { + $rel = relPath($docsRoot, $path); + $content = file_get_contents($path); + + if (! str_starts_with($content, "---\n") && ! str_starts_with($content, "---\r\n")) { + $errors[] = "{$rel}: missing YAML frontmatter"; + continue; + } + + [$front, $body] = splitFrontmatter($content); + + $isVersionRoot = (str_replace(DIRECTORY_SEPARATOR, '/', $path) === "{$docsRoot}/_index.md"); + + if (! isset($front['title'])) { + $errors[] = "{$rel}: frontmatter missing `title`"; + } + + $needsWeight = ! $isVersionRoot; + + if ($needsWeight && ! isset($front['weight'])) { + $errors[] = "{$rel}: frontmatter missing `weight`"; + } + + foreach (findLinks($body) as [$linkLine, $href]) { + if (str_starts_with($href, '/docs/')) { + $errors[] = "{$rel}:{$linkLine}: legacy /docs/... link -- use /documentation/{package}/{version}/..."; + continue; + } + + if (str_starts_with($href, './') || str_starts_with($href, '../')) { + $errors[] = "{$rel}:{$linkLine}: relative link `{$href}` -- use absolute /documentation/{package}/{version}/..."; + continue; + } + + if (str_starts_with($href, '/documentation/')) { + $target = resolveDocTarget($docsRoot, $href, $packageSlug); + if ($target === null) { + $errors[] = "{$rel}:{$linkLine}: broken link `{$href}` (no matching doc file)"; + } + } + } +} + +if ($errors === []) { + $count = count($mdFiles); + echo "docs-lint: OK ({$count} files)\n"; + exit(0); +} + +fwrite(STDERR, "docs-lint: " . count($errors) . " issue(s)\n"); +foreach ($errors as $err) { + fwrite(STDERR, " {$err}\n"); +} +exit(1); + +function relPath(string $root, string $path): string +{ + $root = rtrim($root, '/\\'); + if (str_starts_with($path, $root)) { + return ltrim(substr($path, strlen($root)), '/\\'); + } + return $path; +} + +function splitFrontmatter(string $content): array +{ + $parts = preg_split('/^---\s*$/m', $content, 3); + if (count($parts) < 3) { + return [[], $content]; + } + + $front = []; + foreach (preg_split('/\R/', trim($parts[1])) as $line) { + if (! preg_match('/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/', $line, $m)) { + continue; + } + $front[$m[1]] = trim($m[2], " \t\"'"); + } + + return [$front, $parts[2]]; +} + +function findLinks(string $body): array +{ + $links = []; + $lineNum = 0; + foreach (preg_split('/\R/', $body) as $line) { + $lineNum++; + if (preg_match_all('/\]\(([^)]+)\)/', $line, $m)) { + foreach ($m[1] as $href) { + $href = explode(' ', $href, 2)[0]; + $links[] = [$lineNum, $href]; + } + } + } + return $links; +} + +function resolveDocTarget(string $docsRoot, string $href, string $packageSlug): ?string +{ + $path = parse_url($href, PHP_URL_PATH); + if (! is_string($path)) { + return null; + } + + $parts = array_values(array_filter(explode('/', $path), fn ($p) => $p !== '')); + + if (count($parts) < 2 || $parts[0] !== 'documentation') { + return null; + } + + // Cross-package link: cannot validate against this package's filesystem, + // accept as opaque. The site controller resolves it at request time. + if ($parts[1] !== $packageSlug) { + return $href; + } + + // /documentation/{pkg} alone -- controller redirects to the default + // version. As long as the package has docs, this is valid. + if (count($parts) === 2) { + return is_file($docsRoot . '/_index.md') ? $docsRoot . '/_index.md' : null; + } + + $slugParts = array_slice($parts, 3); + if ($slugParts === []) { + return is_file($docsRoot . '/_index.md') ? $docsRoot . '/_index.md' : null; + } + + $slug = implode('/', $slugParts); + $candidates = [ + $docsRoot . '/' . $slug . '.md', + $docsRoot . '/' . $slug . '/_index.md', + ]; + + foreach ($candidates as $c) { + if (is_file($c)) { + return $c; + } + } + + return null; +} diff --git a/bin/install-hooks b/bin/install-hooks new file mode 100755 index 0000000..1f95876 --- /dev/null +++ b/bin/install-hooks @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Install git hooks for this package. +# Run once after cloning: bin/install-hooks + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +hooks_dir="${repo_root}/.git/hooks" + +if [[ ! -d "${hooks_dir}" ]]; then + echo "install-hooks: no .git/hooks dir (not a git checkout?)" >&2 + exit 1 +fi + +cat > "${hooks_dir}/pre-commit" <<'HOOK' +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" + +# Only run if any docs/ or bin/docs-lint change is staged. +if ! git diff --cached --name-only --diff-filter=ACMR | grep -Eq '^(docs/|bin/docs-lint$)'; then + exit 0 +fi + +"${repo_root}/bin/docs-lint" +HOOK + +chmod +x "${hooks_dir}/pre-commit" +echo "install-hooks: pre-commit hook installed -> ${hooks_dir}/pre-commit" diff --git a/docs/api-reference/widget-contract.md b/docs/api-reference/widget-contract.md index 10f4771..4add60e 100644 --- a/docs/api-reference/widget-contract.md +++ b/docs/api-reference/widget-contract.md @@ -8,7 +8,7 @@ All widgets implement `Crumbls\Layup\Contracts\Widget`. This interface defines t - `Crumbls\Layup\View\BaseBladeWidget` -- renders through a Blade view component (the default). - `Crumbls\Layup\View\BaseLivewireWidget` -- renders through a Livewire component (opt-in, requires `livewire/livewire`). -`Crumbls\Layup\View\BaseWidget` is an abstract alias for `BaseBladeWidget` retained for backwards compatibility. New code may extend either base directly. See [Livewire-rendered widgets](../advanced/livewire-widgets.md) for when each base applies. +`Crumbls\Layup\View\BaseWidget` is an abstract alias for `BaseBladeWidget` retained for backwards compatibility. New code may extend either base directly. See [Livewire-rendered widgets](/documentation/layup/v1/advanced/livewire-widgets) for when each base applies. ## Interface methods @@ -85,7 +85,7 @@ The interface deliberately omits `render()`. Each base class adds it with the ap **Render mechanism:** -The base mounts the named Livewire component via ``, passing the widget's `$data` as a `data` prop and the recursively-rendered children as the default slot. See [Livewire-rendered widgets](../advanced/livewire-widgets.md) for the full architecture, children-handling model, and migration patterns. +The base mounts the named Livewire component via ``, passing the widget's `$data` as a `data` prop and the recursively-rendered children as the default slot. See [Livewire-rendered widgets](/documentation/layup/v1/advanced/livewire-widgets) for the full architecture, children-handling model, and migration patterns. ## HasWidgetDefaults trait From 4218b36d1781c7097bc4b494c5dc04b70597e90b Mon Sep 17 00:00:00 2001 From: Chase Miller Date: Thu, 30 Apr 2026 21:08:00 -0600 Subject: [PATCH 2/2] Harden bin/docs-lint against three edge cases - Path traversal in /documentation/{pkg}/{ver}/... slug resolution. parse_url() yields raw URL segments; '..' could reach files outside $docsRoot via is_file(), silently validating a broken link. Reject '.' / '..' / empty segments before building the candidate path, and add a defence-in-depth realpath check that the resolved file is actually inside $docsRoot. - Windows path comparison for $isVersionRoot. The LHS was normalized (DIRECTORY_SEPARATOR -> '/') but the RHS embedded the un-normalized $docsRoot, so the comparison never matched on Windows -- the version root would be falsely flagged as missing `weight`. Normalize both sides via $normalizedDocsRoot once, then compare normalized paths. - file_get_contents() returning false. Under declare(strict_types=1), passing false to str_starts_with() throws TypeError, crashing the linter on any unreadable file (permission/race). Detect false explicitly and emit a per-file 'unreadable file' error instead. Verified all three behaviours with throwaway fixtures: - traversal link -> 'broken link' error (not silent acceptance) - chmod 000 file -> 'unreadable file' error (not TypeError) - clean docs/ tree -> still passes (32 files) --- bin/docs-lint | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/bin/docs-lint b/bin/docs-lint index ea7887a..aec4ece 100755 --- a/bin/docs-lint +++ b/bin/docs-lint @@ -69,9 +69,16 @@ foreach ($it as $file) { $mdFiles[] = $file->getPathname(); } +$normalizedDocsRoot = str_replace(DIRECTORY_SEPARATOR, '/', $docsRoot); + foreach ($mdFiles as $path) { $rel = relPath($docsRoot, $path); - $content = file_get_contents($path); + $content = @file_get_contents($path); + + if ($content === false) { + $errors[] = "{$rel}: unreadable file"; + continue; + } if (! str_starts_with($content, "---\n") && ! str_starts_with($content, "---\r\n")) { $errors[] = "{$rel}: missing YAML frontmatter"; @@ -80,7 +87,8 @@ foreach ($mdFiles as $path) { [$front, $body] = splitFrontmatter($content); - $isVersionRoot = (str_replace(DIRECTORY_SEPARATOR, '/', $path) === "{$docsRoot}/_index.md"); + $normalizedPath = str_replace(DIRECTORY_SEPARATOR, '/', $path); + $isVersionRoot = ($normalizedPath === "{$normalizedDocsRoot}/_index.md"); if (! isset($front['title'])) { $errors[] = "{$rel}: frontmatter missing `title`"; @@ -193,6 +201,17 @@ function resolveDocTarget(string $docsRoot, string $href, string $packageSlug): } $slugParts = array_slice($parts, 3); + + // Reject traversal segments and empties before building the path. + // Without this, a link like /documentation/layup/v1/../../etc/passwd + // would resolve to a file outside $docsRoot and is_file() would + // happily accept it, marking a broken link as valid. + foreach ($slugParts as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..') { + return null; + } + } + if ($slugParts === []) { return is_file($docsRoot . '/_index.md') ? $docsRoot . '/_index.md' : null; } @@ -203,10 +222,26 @@ function resolveDocTarget(string $docsRoot, string $href, string $packageSlug): $docsRoot . '/' . $slug . '/_index.md', ]; + $normalizedDocsRoot = str_replace(DIRECTORY_SEPARATOR, '/', realpath($docsRoot) ?: $docsRoot); + foreach ($candidates as $c) { - if (is_file($c)) { - return $c; + if (! is_file($c)) { + continue; + } + + // Defence in depth: ensure the resolved path is actually inside + // the docs root, even if a slug segment slipped past validation. + $resolved = realpath($c); + if ($resolved === false) { + continue; } + + $normalizedResolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved); + if (! str_starts_with($normalizedResolved, $normalizedDocsRoot . '/')) { + continue; + } + + return $c; } return null;