diff --git a/bin/docs-lint b/bin/docs-lint new file mode 100755 index 0000000..aec4ece --- /dev/null +++ b/bin/docs-lint @@ -0,0 +1,248 @@ +#!/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(); +} + +$normalizedDocsRoot = str_replace(DIRECTORY_SEPARATOR, '/', $docsRoot); + +foreach ($mdFiles as $path) { + $rel = relPath($docsRoot, $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"; + continue; + } + + [$front, $body] = splitFrontmatter($content); + + $normalizedPath = str_replace(DIRECTORY_SEPARATOR, '/', $path); + $isVersionRoot = ($normalizedPath === "{$normalizedDocsRoot}/_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); + + // 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; + } + + $slug = implode('/', $slugParts); + $candidates = [ + $docsRoot . '/' . $slug . '.md', + $docsRoot . '/' . $slug . '/_index.md', + ]; + + $normalizedDocsRoot = str_replace(DIRECTORY_SEPARATOR, '/', realpath($docsRoot) ?: $docsRoot); + + foreach ($candidates as $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; +} 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