diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 805f170..8c2683c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -271,6 +271,9 @@ jobs: - name: Install Node.js dependencies run: npm ci + - name: Check documentation pages + run: npm run check:docs + - name: Build CSS run: npm run build-css-prod @@ -284,3 +287,7 @@ jobs: - name: Check internal links run: zola check --skip-external-links + + # zola check only follows @/ links; this site writes absolute ones. + - name: Check absolute links and anchors + run: npm run check:links diff --git a/.gitignore b/.gitignore index a116257..8e2a8ac 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,5 @@ cache/ /agnostic-ai.local.yaml /content/AGENTS.md /content/practice/AGENTS.md +/.agnostic-ai/.command-lock # <<< agnostic-ai (managed) <<< diff --git a/build/run-doc-snippets.php b/build/run-doc-snippets.php index 9f52764..203aac9 100644 --- a/build/run-doc-snippets.php +++ b/build/run-doc-snippets.php @@ -4,7 +4,9 @@ /** * Executes every ```phel snippet in content/ against the real Phel runtime, - * so documentation cannot silently rot when the language changes. + * so documentation cannot silently rot when the language changes. Documented + * results (`; => value`) are checked too (see ExpectedValueRewriter); a + * mismatch fails like a broken snippet. * * Each snippet runs in an isolated `phel run` subprocess under a wall-clock cap * enforced here in PHP, so a hanging or memory-heavy snippet cannot take down @@ -15,7 +17,8 @@ * templates and narrative continuations exist by design. Those are either * auto-skipped (see SnippetExtractor) or recorded in a checked-in baseline * (build/doc-snippets-baseline.json), keyed by snippet content so the entry - * survives line moves. The suite fails on: + * survives line moves. A mismatch entry adds the expectation's text to that + * key. The suite fails on: * - a NEW failure not present in the baseline (a regression), or * - a baseline entry that now passes (ratchet: remove it). * @@ -27,6 +30,7 @@ require __DIR__ . '/../vendor/autoload.php'; +use PhelWeb\DocSnippet\ExpectedValueRewriter; use PhelWeb\DocSnippet\SnippetExtractor; $root = dirname(__DIR__); @@ -47,6 +51,7 @@ $CONCURRENCY = 8; $extractor = new SnippetExtractor(); +$rewriter = new ExpectedValueRewriter(); /** Stable per-snippet key, independent of its line number. */ $keyOf = static function (string $relFile, string $code): string { @@ -54,6 +59,11 @@ return $relFile . '#' . substr(sha1((string) $normalized), 0, 12); }; +$expectationKeyOf = static function (string $snippetKey, string $text): string { + $normalized = preg_replace('/\s+/', ' ', trim($text)); + return $snippetKey . '=>' . substr(sha1((string) $normalized), 0, 8); +}; + // Collect markdown files. $files = []; foreach ($paths as $path) { @@ -77,6 +87,7 @@ $jobs = []; $skipped = 0; +$skippedExpectations = []; $index = 0; foreach ($files as $file) { $rel = str_replace($root . '/', '', $file); @@ -88,16 +99,28 @@ $index++; $code = $snippet['code']; $hasNs = preg_match('/^\s*\(ns\s/', $code) === 1; - // Dot separator, not backslash: Phel deprecated `\` in namespaces, and - // the notice it prints carries a full PHP stack trace (~150KB of stderr - // per snippet), which both slows the run and floods the pipes. - $source = $hasNs ? $code : "(ns doctest.s{$index})\n{$code}"; + // `user`, as in the REPL, so documented values such as `(user.point 1 2)` match. + $header = $hasNs ? '' : "(ns user)\n"; + $rewrite = $rewriter->rewrite($code, $rel, $snippet['startLine']); + array_push($skippedExpectations, ...$rewrite['skipped']); + $key = $keyOf($rel, $code); $tmpFile = "{$tmpDir}/s{$index}.phel"; - file_put_contents($tmpFile, $source); + file_put_contents($tmpFile, $header . $rewrite['code']); + $originalFile = null; + if ($rewrite['expectations'] !== []) { + $originalFile = "{$tmpDir}/s{$index}-original.phel"; + file_put_contents($originalFile, $header . $code); + } + $expectations = []; + foreach ($rewrite['expectations'] as $expectation) { + $expectations[$expectation['location']] = $expectation + ['key' => $expectationKeyOf($key, $expectation['text'])]; + } $jobs[] = [ - 'key' => $keyOf($rel, $code), + 'key' => $key, 'location' => "{$rel}:{$snippet['startLine']}", 'file' => $tmpFile, + 'originalFile' => $originalFile, + 'expectations' => $expectations, ]; } } @@ -220,6 +243,28 @@ $passed = 0; $queue = $jobs; +// Expectations of a snippet whose instrumented run passed: a PHEL-DOC-MISMATCH +// line in stdout fails that expectation, its absence passes it. +$mismatches = []; // key => ['location' => ..., 'note' => ...] +$checked = 0; +$unverifiable = []; +$recordExpectations = static function (array $job, string $out) use (&$mismatches, &$checked, &$passedKeys): void { + $marker = preg_quote(ExpectedValueRewriter::MISMATCH_MARKER, '/'); + preg_match_all('/^' . $marker . ' (\S+) (expected .*)$/m', $out, $found, PREG_SET_ORDER); + $notes = []; + foreach ($found as $m) { + $notes[$m[1]] = $m[2]; + } + foreach ($job['expectations'] as $location => $expectation) { + ++$checked; + if (isset($notes[$location])) { + $mismatches[$expectation['key']] = ['location' => $location, 'note' => $notes[$location]]; + } else { + $passedKeys[$expectation['key']] = true; + } + } +}; + while ($queue !== [] || $running !== []) { while (count($running) < $CONCURRENCY && $queue !== []) { $running[] = $startProc(array_shift($queue)); @@ -247,6 +292,7 @@ if ($exit === 0 && !$r['timedOut']) { $passed++; $passedKeys[$job['key']] = true; + $recordExpectations($job, $r['out']); } else { $failed[$job['key']] = [ 'location' => $job['location'], @@ -286,6 +332,23 @@ unset($failed[$key]); $passed++; $passedKeys[$key] = true; + $recordExpectations($job, $out); + continue; + } + $failed[$key]['note'] = $noteOf($exit, $out, $err, $timedOut); + if ($job['originalFile'] === null) { + continue; + } + // The checks must never break a snippet that runs on its own: if the + // original passes, its expectations are reported as unverifiable instead. + [$exit, $out, $err, $timedOut] = $reap($startProc(['file' => $job['originalFile']] + $job, 'original-')); + if ($exit === 0 && !$timedOut) { + unset($failed[$key]); + $passed++; + $passedKeys[$key] = true; + foreach ($job['expectations'] as $expectation) { + $unverifiable[] = $expectation; + } } else { $failed[$key]['note'] = $noteOf($exit, $out, $err, $timedOut); } @@ -305,9 +368,12 @@ $rmTree($tmpDir); // --- Baseline handling --- +$failures = $failed + $mismatches; +$expectationSummary = "Expectations: checked {$checked}, mismatched " . count($mismatches) . ', unverifiable ' . count($unverifiable); + if ($updateBaseline) { $entries = []; - foreach ($failed as $key => $info) { + foreach ($failures as $key => $info) { $entries[$key] = $info['location'] . ' :: ' . $info['note']; } ksort($entries); @@ -315,8 +381,9 @@ $baselinePath, json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n", ); - echo "\nWrote baseline with " . count($entries) . " known-failing snippets to build/doc-snippets-baseline.json\n"; + echo "\nWrote baseline with " . count($entries) . " known failures to build/doc-snippets-baseline.json\n"; echo "Snippets: {$total} Passed: {$passed} Failed: " . count($failed) . " Skipped: {$skipped}\n"; + echo "{$expectationSummary}\n"; exit(0); } @@ -325,12 +392,13 @@ $baseline = (array) json_decode((string) file_get_contents($baselinePath), true); } -$regressions = array_diff_key($failed, $baseline); // failing now, not in baseline +$regressions = array_diff_key($failures, $baseline); // failing now, not in baseline $fixed = array_intersect_key($passedKeys, $baseline); // in baseline but now passing echo "\n"; echo "Snippets: {$total} Passed: {$passed} Failed: " . count($failed) . " Skipped: {$skipped} Baseline: " . count($baseline) . "\n"; +echo "{$expectationSummary}\n"; $exit = 0; @@ -350,15 +418,29 @@ } } -if ($verbose && $failed !== []) { +if ($verbose && $failures !== []) { echo "\nAll current failures:\n"; - foreach ($failed as $info) { + foreach ($failures as $info) { echo " {$info['location']} {$info['note']}\n"; } } +if ($verbose && $unverifiable !== []) { + echo "\nUnverifiable expectations (the instrumented snippet fails, the original passes):\n"; + foreach ($unverifiable as $expectation) { + echo " {$expectation['location']} {$expectation['text']}\n"; + } +} + +if ($verbose && $skippedExpectations !== []) { + echo "\nSkipped expectations:\n"; + foreach ($skippedExpectations as $expectation) { + echo " {$expectation['location']} [{$expectation['reason']}] {$expectation['text']}\n"; + } +} + if ($exit === 0) { - echo "\nNo regressions. " . count($baseline) . " known-unrunnable snippets tracked in baseline.\n"; + echo "\nNo regressions. " . count($baseline) . " known failures tracked in baseline.\n"; } exit($exit); diff --git a/build/src/DocSnippet/ExpectedValueRewriter.php b/build/src/DocSnippet/ExpectedValueRewriter.php new file mode 100644 index 0000000..87f405b --- /dev/null +++ b/build/src/DocSnippet/ExpectedValueRewriter.php @@ -0,0 +1,347 @@ + EXPECTED`, or a + * `; => EXPECTED` line right below FORM) into checks that print a + * PHEL-DOC-MISMATCH line when the runtime value differs. The rewrite keeps the + * line count, so error locations still point at the markdown source. + * + * @psalm-type TExpectation = array{line: int, location: string, text: string, form: string, expected: string} + * @psalm-type TSkippedExpectation = array{line: int, location: string, text: string, reason: string} + * @psalm-type TRewrite = array{ + * code: string, + * expectations: list, + * skipped: list, + * } + */ +final class ExpectedValueRewriter +{ + public const MISMATCH_MARKER = 'PHEL-DOC-MISMATCH'; + + private const DEFINITION_PREFIXES = [ + '(def', + '(ns', + '(require', + '(use', + '(declare', + '(comment', + ]; + + private const UNREADABLE_PREFIXES = ['#\'', '#<', '<']; + + /** Words that describe a value instead of printing it; keywords such as `:error` stay checkable. */ + private const NON_VALUE_WORDS = '/(? ')', '[' => ']', '{' => '}']; + + /** + * @return TRewrite + */ + public function rewrite(string $code, string $file, int $startLine): array + { + $lines = explode("\n", $code); + $codeEnds = []; + $expectations = []; + $skipped = []; + $depth = 0; + $inString = false; + $formStart = null; + $lastForm = null; + + foreach ($lines as $offset => $line) { + $topLevelAtStart = $depth === 0 && !$inString; + $commentStart = $this->scanLine($line, $depth, $inString); + $codeEnds[$offset] = $commentStart ?? strlen($line); + $hasCode = trim(substr($line, 0, $codeEnds[$offset])) !== ''; + + if ($topLevelAtStart && $hasCode) { + $formStart = $offset; + } + $closedForm = null; + if ($formStart !== null && $depth === 0 && !$inString && $hasCode) { + $closedForm = ['start' => $formStart, 'end' => $offset]; + $formStart = null; + } + + $expectedText = $this->expectedText($line, $commentStart); + if ($expectedText === null) { + if ($closedForm !== null) { + $lastForm = $closedForm; + } elseif ($hasCode) { + $lastForm = null; + } + continue; + } + + $lineNumber = $startLine + $offset; + $location = "{$file}:{$lineNumber}"; + + $span = null; + $reason = null; + if ($hasCode) { + $span = $closedForm; + $reason = $span === null ? 'inside an enclosing form' : null; + } elseif (!$topLevelAtStart) { + $reason = 'inside an enclosing form'; + } elseif ($lastForm !== null && $lastForm['end'] === $offset - 1) { + $span = $lastForm; + } else { + $reason = 'result comment not right below a form'; + } + $lastForm = null; + + $form = $span === null ? '' : $this->formText($lines, $codeEnds, $span['start'], $span['end']); + $shownForm = $span === null ? substr($line, 0, $codeEnds[$offset]) : $form; + $text = trim((string) preg_replace('/\s+/', ' ', $shownForm . ' ' . substr($line, (int) $commentStart))); + $reason ??= $this->skipReason($form, $expectedText); + if ($reason !== null || $span === null) { + $skipped[] = ['line' => $lineNumber, 'location' => $location, 'text' => $text, 'reason' => (string) $reason]; + continue; + } + + $expected = $this->firstExpectedForm($expectedText); + $expectations[] = [ + 'line' => $lineNumber, + 'location' => $location, + 'text' => $text, + 'form' => $form, + 'expected' => $expected, + ]; + $this->instrument($lines, $codeEnds, $span['start'], $offset, $hasCode, $expected, $location); + } + + return [ + 'code' => implode("\n", $lines), + 'expectations' => $expectations, + 'skipped' => $skipped, + ]; + } + + /** + * The first form of the documented value: a delimited form when it opens + * with a bracket or a quote, otherwise the text up to the first whitespace. + */ + public function firstExpectedForm(string $expectedText): string + { + $text = ltrim($expectedText); + $end = $this->delimitedValueEnd($text); + if ($end !== null) { + return substr($text, 0, $end); + } + + return (preg_split('/\s/', $text, 2) ?: [''])[0]; + } + + private function expectedText(string $line, ?int $commentStart): ?string + { + if ($commentStart === null) { + return null; + } + if (preg_match('/^;(?!;)\s*=>(.*)$/', substr($line, $commentStart), $m) !== 1) { + return null; + } + + return trim($m[1]); + } + + private function skipReason(string $form, string $expectedText): ?string + { + if (!$this->isSingleForm($form)) { + return 'not one form'; + } + foreach (self::DEFINITION_PREFIXES as $prefix) { + if (str_starts_with($form, $prefix)) { + return 'definition form'; + } + } + if ($expectedText === '') { + return 'no expected value'; + } + if (str_contains($expectedText, '...')) { + return 'elided value'; + } + foreach (self::UNREADABLE_PREFIXES as $prefix) { + if (str_starts_with($expectedText, $prefix)) { + return 'unreadable value'; + } + } + if ($this->opensDelimitedValue($expectedText) && $this->delimitedValueEnd($expectedText) === null) { + return 'value does not close on this line'; + } + if (preg_match(self::GENERATED_SYMBOL, $expectedText) === 1) { + return 'generated symbol'; + } + $outsideStrings = (string) preg_replace('/"(?:[^"\\\\]|\\\\.)*"/s', '""', $expectedText); + if (preg_match(self::NON_VALUE_WORDS, $outsideStrings, $m) === 1) { + return "describes the value ('" . strtolower($m[1]) . "')"; + } + + return null; + } + + /** + * @param array $lines + * @param array $codeEnds + */ + private function formText(array $lines, array $codeEnds, int $start, int $end): string + { + $parts = []; + for ($i = $start; $i <= $end; $i++) { + $parts[] = substr($lines[$i], 0, $codeEnds[$i]); + } + + return trim(implode("\n", $parts)); + } + + /** + * @param array $lines + * @param array $codeEnds + */ + private function instrument(array &$lines, array $codeEnds, int $start, int $commentLine, bool $sameLine, string $expected, string $location): void + { + $head = '(let [__expected (quote ' . $expected . ') __actual '; + $tail = '] (when-not (or (= __expected __actual) (= (pr-str __expected) (pr-str __actual))) ' + . '(println (str "\n' . self::MISMATCH_MARKER . ' ' . addcslashes($location, '"\\') + . ' expected " (pr-str __expected) " got " (pr-str __actual)))))'; + + if ($sameLine && $start === $commentLine) { + $lines[$start] = $head . trim(substr($lines[$start], 0, $codeEnds[$start])) . $tail; + return; + } + + $lines[$start] = $head . $lines[$start]; + $lines[$commentLine] = $sameLine ? rtrim(substr($lines[$commentLine], 0, $codeEnds[$commentLine])) . $tail : $tail; + } + + /** + * Advances the bracket depth and string state across one line and returns + * the offset of the comment that ends it, if any. + */ + private function scanLine(string $line, int &$depth, bool &$inString): ?int + { + $length = strlen($line); + for ($i = 0; $i < $length; $i++) { + $char = $line[$i]; + if ($inString) { + if ($char === '\\') { + $i++; + } elseif ($char === '"') { + $inString = false; + } + continue; + } + if ($char === '"') { + $inString = true; + } elseif ($char === ';') { + return $i; + } elseif (isset(self::OPENERS[$char])) { + $depth++; + } elseif (in_array($char, self::OPENERS, true)) { + $depth--; + } + } + + return null; + } + + private function isSingleForm(string $form): bool + { + $end = $this->formEnd($form, 0); + + return $end !== null && trim(substr($form, $end)) === ''; + } + + /** Reads through prefixes such as quote, deref, `|` and `#?` to the end of one form. */ + private function formEnd(string $text, int $pos): ?int + { + $length = strlen($text); + while ($pos < $length && ctype_space($text[$pos])) { + $pos++; + } + if ($pos >= $length) { + return null; + } + + $char = $text[$pos]; + if (str_contains("'`,@|^", $char)) { + return $this->formEnd($text, $pos + 1); + } + if ($char === '#' && ($text[$pos + 1] ?? '') === '?') { + return $this->formEnd($text, $pos + 2); + } + if ($char === '#' && ($text[$pos + 1] ?? '') === '{') { + return $this->delimitedEnd($text, $pos + 1); + } + if ($char === '"' || isset(self::OPENERS[$char])) { + return $this->delimitedEnd($text, $pos); + } + if (in_array($char, self::OPENERS, true)) { + return null; + } + + while ($pos < $length && !ctype_space($text[$pos]) && !str_contains('()[]{}";', $text[$pos])) { + $pos++; + } + + return $pos; + } + + private function opensDelimitedValue(string $text): bool + { + $first = $text[str_starts_with($text, '#{') ? 1 : 0] ?? ''; + + return $first === '"' || isset(self::OPENERS[$first]); + } + + private function delimitedValueEnd(string $text): ?int + { + if (!$this->opensDelimitedValue($text)) { + return null; + } + + return $this->delimitedEnd($text, str_starts_with($text, '#{') ? 1 : 0); + } + + /** End offset of the string or bracketed form that opens at $pos; null when it never closes or closes with the wrong bracket. */ + private function delimitedEnd(string $text, int $pos): ?int + { + $length = strlen($text); + $stack = []; + $inString = false; + for ($i = $pos; $i < $length; $i++) { + $char = $text[$i]; + if ($inString) { + if ($char === '\\') { + $i++; + } elseif ($char === '"') { + $inString = false; + if ($stack === []) { + return $i + 1; + } + } + continue; + } + if ($char === '"') { + $inString = true; + } elseif (isset(self::OPENERS[$char])) { + $stack[] = self::OPENERS[$char]; + } elseif (in_array($char, self::OPENERS, true)) { + if (array_pop($stack) !== $char) { + return null; + } + if ($stack === []) { + return $i + 1; + } + } + } + + return null; + } +} diff --git a/build/src/ErrorReference/ErrorReferenceGenerator.php b/build/src/ErrorReference/ErrorReferenceGenerator.php index 50a3f34..b5e8804 100644 --- a/build/src/ErrorReference/ErrorReferenceGenerator.php +++ b/build/src/ErrorReference/ErrorReferenceGenerator.php @@ -234,7 +234,7 @@ private function render(): string description = "Every Phel error code (PHEL001-PHEL404), what it means, and how to fix it." +++ - > This page is the canonical index of error **codes**. To learn how to `throw`, `catch`, and attach data to errors in your own code, see [Error handling](/documentation/language/error-handling/). + This page lists every Phel error code. To learn how to `throw`, `catch`, and attach data to errors in your own code, see [Error handling](/documentation/language/error-handling/). Phel errors are tagged with a stable code like `[PHEL001]`. The code survives wording changes, so it is the reliable thing to search for. An error diff --git a/build/tests/php/DocSnippet/ExpectedValueRewriterTest.php b/build/tests/php/DocSnippet/ExpectedValueRewriterTest.php new file mode 100644 index 0000000..25a7e62 --- /dev/null +++ b/build/tests/php/DocSnippet/ExpectedValueRewriterTest.php @@ -0,0 +1,215 @@ +rewriter = new ExpectedValueRewriter(); + } + + public function test_rewrites_a_single_line_expectation(): void + { + $result = $this->rewriter->rewrite('(+ 1 2) ; => 3', 'doc.md', 10); + + self::assertSame( + '(let [__expected (quote 3) __actual (+ 1 2)] (when-not (or (= __expected __actual) (= (pr-str __expected) (pr-str __actual))) ' + . '(println (str "\nPHEL-DOC-MISMATCH doc.md:10 expected " (pr-str __expected) " got " (pr-str __actual)))))', + $result['code'], + ); + self::assertCount(1, $result['expectations']); + self::assertSame('doc.md:10', $result['expectations'][0]['location']); + self::assertSame('(+ 1 2)', $result['expectations'][0]['form']); + self::assertSame('3', $result['expectations'][0]['expected']); + self::assertSame([], $result['skipped']); + } + + public function test_keeps_the_line_count(): void + { + $code = "(def a 1)\n(+ a 1) ; => 2\n(let [b 2]\n (* a b)) ; => 2\n(inc a)\n; => 2"; + + $result = $this->rewriter->rewrite($code, 'doc.md', 1); + + self::assertCount(3, $result['expectations']); + self::assertSame(6, substr_count($result['code'], "\n") + 1); + } + + public function test_runner_detects_mismatches_after_printed_output(): void + { + $file = tempnam(sys_get_temp_dir(), 'phel-doc-expectations-'); + self::assertIsString($file); + $root = dirname(__DIR__, 4); + + try { + file_put_contents($file, <<<'MARKDOWN' +```phel +(do (print "cleanup") "recovered") ; => "wrong" +``` + +```phel +"recovered" ; => "wrong" +``` + +```phel +(do (print "cleanup") "recovered") ; => "recovered" +``` +MARKDOWN); + $process = proc_open( + [PHP_BINARY, $root . '/build/run-doc-snippets.php', $file], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['redirect', 1]], + $pipes, + $root, + ); + self::assertIsResource($process); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + fclose($pipes[1]); + $exit = proc_close($process); + + self::assertSame(1, $exit, $output); + self::assertStringContainsString('Expectations: checked 3, mismatched 2, unverifiable 0', $output); + self::assertStringContainsString($file . ':2 expected "wrong" got "recovered"', $output); + self::assertStringContainsString($file . ':6 expected "wrong" got "recovered"', $output); + } finally { + unlink($file); + } + } + + public function test_multi_line_form_is_wrapped_from_its_first_line(): void + { + $result = $this->rewriter->rewrite("(let [b 2]\n (* 3 b)) ; => 6", 'doc.md', 5); + $lines = explode("\n", $result['code']); + + self::assertSame('(let [__expected (quote 6) __actual (let [b 2]', $lines[0]); + self::assertStringStartsWith(' (* 3 b))] (when-not', $lines[1]); + self::assertStringContainsString('doc.md:6 expected', $lines[1]); + self::assertSame("(let [b 2]\n (* 3 b))", $result['expectations'][0]['form']); + } + + public function test_result_comment_right_below_a_form_checks_that_form(): void + { + $result = $this->rewriter->rewrite("(str \"a\" \"b\")\n; => \"ab\"", 'doc.md', 1); + $lines = explode("\n", $result['code']); + + self::assertSame('(let [__expected (quote "ab") __actual (str "a" "b")', $lines[0]); + self::assertStringStartsWith('] (when-not', $lines[1]); + self::assertSame('(str "a" "b") ; => "ab"', $result['expectations'][0]['text']); + } + + public function test_arrow_inside_a_string_is_not_an_expectation(): void + { + $code = '(println "a ; => b")'; + + $result = $this->rewriter->rewrite($code, 'doc.md', 1); + + self::assertSame($code, $result['code']); + self::assertSame([], $result['expectations']); + self::assertSame([], $result['skipped']); + } + + public function test_arrow_after_a_string_holding_a_semicolon_is_an_expectation(): void + { + $result = $this->rewriter->rewrite('(str "a;b") ; => "a;b"', 'doc.md', 1); + + self::assertSame('"a;b"', $result['expectations'][0]['expected']); + } + + public function test_double_semicolon_is_not_an_expectation(): void + { + $code = '(+ 1 2) ;; => 3'; + + $result = $this->rewriter->rewrite($code, 'doc.md', 1); + + self::assertSame($code, $result['code']); + self::assertSame([], $result['expectations']); + } + + public function test_plain_comments_are_left_alone(): void + { + $code = "; a comment\n(+ 1 2) ; prints nothing"; + + $result = $this->rewriter->rewrite($code, 'doc.md', 1); + + self::assertSame($code, $result['code']); + self::assertSame([], $result['skipped']); + } + + /** + * @return iterable + */ + public static function skippedLines(): iterable + { + yield 'definition' => ['(def x (+ 1 2)) ; => 3', 'definition form']; + yield 'function definition' => ['(defn f [] 1) ; => 1', 'definition form']; + yield 'namespace' => ['(ns app) ; => nil', 'definition form']; + yield 'two forms' => ['(inc 1) (inc 2) ; => 2 3', 'not one form']; + yield 'nested' => ["(do\n (inc 1) ; => 2\n 3)", 'inside an enclosing form']; + yield 'own line after a blank' => ["(inc 1)\n\n; => 2", 'result comment not right below a form']; + yield 'own line at the start' => ['; => 2', 'result comment not right below a form']; + yield 'elided' => ['(range 100) ; => (0 1 2 ...)', 'elided value']; + yield 'var' => ['(var x) ; => #\'user/x', 'unreadable value']; + yield 'object' => ['(fn [] 1) ; => ', 'unreadable value']; + yield 'description' => ['(DateTime.) ; => DateTime instance', "describes the value ('instance')"]; + yield 'random' => ['(rand) ; => 0.42 (random)', "describes the value ('random')"]; + yield 'throws' => ['(/ 1 0) ; => throws DivisionByZeroError', "describes the value ('throws')"]; + yield 'unclosed value' => ['(range 3) ; => (0 1', 'value does not close on this line']; + yield 'generated symbol' => ['(gensym) ; => __phel_1', 'generated symbol']; + yield 'empty' => ['(inc 1) ; =>', 'no expected value']; + } + + #[DataProvider('skippedLines')] + public function test_skips_lines_it_cannot_check(string $code, string $reason): void + { + $result = $this->rewriter->rewrite($code, 'doc.md', 1); + + self::assertSame([], $result['expectations']); + self::assertSame($code, $result['code']); + self::assertCount(1, $result['skipped']); + self::assertSame($reason, $result['skipped'][0]['reason']); + } + + public function test_keyword_or_string_holding_a_skip_word_is_still_checked(): void + { + $result = $this->rewriter->rewrite("(f) ; => {:error nil}\n(g) ; => \"error: boom\"", 'doc.md', 1); + + self::assertCount(2, $result['expectations']); + } + + /** + * @return iterable + */ + public static function expectedTexts(): iterable + { + yield 'number with a note' => ['1337 (hex)', '1337']; + yield 'string' => ['"a b" (string)', '"a b"']; + yield 'string with escaped quote' => ['"say \"hi\"" ok', '"say \"hi\""']; + yield 'list' => ['(1 2 3) lazily', '(1 2 3)']; + yield 'nested vector' => ['[[1 :a] [2 :b]], in order', '[[1 :a] [2 :b]]']; + yield 'map with string holding a bracket' => ['{:a "]"} done', '{:a "]"}']; + yield 'set' => ['#{1 2} unordered', '#{1 2}']; + yield 'keyword' => [':timeout (the future keeps running)', ':timeout']; + yield 'nil' => ['nil', 'nil']; + } + + #[DataProvider('expectedTexts')] + public function test_takes_the_first_form_of_the_expected_text(string $text, string $first): void + { + self::assertSame($first, $this->rewriter->firstExpectedForm($text)); + } + + public function test_reader_shortcuts_count_as_one_form(): void + { + $result = $this->rewriter->rewrite("@a ; => 1\n'(1 2) ; => (1 2)\n#?(:phel 1 :default 0) ; => 1", 'doc.md', 1); + + self::assertCount(3, $result['expectations']); + } +} diff --git a/content/_index.md b/content/_index.md index 2de2147..3ee9ebe 100644 --- a/content/_index.md +++ b/content/_index.md @@ -1,6 +1,6 @@ +++ title = "Phel: A Functional Lisp Dialect for PHP Developers" -aliases = ["/documentation/why-phel/"] +aliases = ["/documentation/why-phel/", "/documentation/reference/in-the-wild/"] +++
diff --git a/content/documentation/getting-started.md b/content/documentation/getting-started.md index 9b02423..ecb9bd8 100644 --- a/content/documentation/getting-started.md +++ b/content/documentation/getting-started.md @@ -4,6 +4,8 @@ weight = 2 description = "Create a Phel project, try the REPL, run your first file, and learn the project layout." +++ +This page takes you from an empty folder to a running Phel project. You create the project with Composer, try code in the REPL, run a file, and see where each part of the project lives. + ## Requirements - **PHP 8.5+** (`php -v`) diff --git a/content/documentation/guides/_index.md b/content/documentation/guides/_index.md index e3f5570..f54a935 100644 --- a/content/documentation/guides/_index.md +++ b/content/documentation/guides/_index.md @@ -5,4 +5,5 @@ sort_by = "weight" insert_anchor_links = "right" template = "section-page.html" description = "Task-focused guides: build a web app, move over from PHP or Clojure, copy working recipes, and test, debug, tune, and deploy Phel code." +aliases = ["/documentation/tutorials/", "/documentation/tooling/authoring-libraries/"] +++ diff --git a/content/documentation/guides/cookbook.md b/content/documentation/guides/cookbook.md index 94b15e5..400650d 100644 --- a/content/documentation/guides/cookbook.md +++ b/content/documentation/guides/cookbook.md @@ -2,7 +2,7 @@ title = "Cookbook" weight = 4 description = "Runnable Phel recipes grouped by task: collections, files, JSON, HTTP, dates, and CLI tools." -aliases = ["/documentation/cookbook", "/documentation/one-liners", "/documentation/guides/one-liners"] +aliases = ["/documentation/cookbook", "/documentation/one-liners", "/documentation/guides/one-liners", "/documentation/reference/one-liners"] +++ Copy a recipe, change the data, and run it. For the concepts behind the code, see the [Language](/documentation/language/) section. @@ -27,11 +27,11 @@ Copy a recipe, change the data, and run it. For the concepts behind the code, se (map #(update % :name str/upper-case)) (sort-by :age) (group-by :role))) -;; => {"engineer" [{:name "ALICE" ...} {:name "CHARLIE" ...}], "manager" [{:name "DIANA" ...}]} +; => {"engineer" [{:name "ALICE" ...} {:name "CHARLIE" ...}], "manager" [{:name "DIANA" ...}]} (foreach [role members by-role] (println role (count members))) -;; prints: engineer 2, manager 1 +; prints: engineer 2, manager 1 (->> users (map :role) (into #{})) ; => #{"engineer" "designer" "manager"} ``` @@ -101,8 +101,8 @@ A transducer is a pipeline you define once and apply to any target (`into`, `tra (mapv #(zipmap ks %) rows))) (read-csv path) -;; => [{:name "Alice", :email "alice@example.com", :role "admin"} -;; {:name "Bob", :email "bob@example.com", :role "editor"}] +; => [{:name "Alice", :email "alice@example.com", :role "admin"} +; {:name "Bob", :email "bob@example.com", :role "editor"}] ``` `csv-seq` reads rows lazily as vectors of strings. Zipping each row with the header row gives you maps with keyword keys. @@ -175,7 +175,7 @@ A transducer is a pipeline you define once and apply to any target (`into`, `tra (DateTimeImmutable. "2026-12-31"))) ; => 364 (.format (DateTimeImmutable/createFromFormat "d/m/Y" "25/12/2026") "Y-m-d") -;; => "2026-12-25" +; => "2026-12-25" ``` Use `DateTimeImmutable`: every change returns a new object, as Phel values do. `<` and `>` compare dates directly. The `#inst "2026-01-15T12:00:00Z"` literal also reads as a `DateTimeImmutable`. diff --git a/content/documentation/guides/debugging.md b/content/documentation/guides/debugging.md index c3a39ec..ca9dec7 100644 --- a/content/documentation/guides/debugging.md +++ b/content/documentation/guides/debugging.md @@ -2,7 +2,7 @@ title = "Debugging" weight = 11 description = "Debug Phel code step by step: dbg, tap>, trace, stack traces, REPL breakpoints, Xdebug, PHP dump tools, compiled PHP, and profiling." -aliases = ["/documentation/debugging/", "/documentation/tooling/php-tools/"] +aliases = ["/documentation/debugging/", "/documentation/tooling/php-tools/", "/documentation/debug/", "/documentation/debug/php-tools/"] +++ Start with the simplest tool that answers your question. @@ -28,7 +28,7 @@ Start with the simplest tool that answers your question. (area 3 4) ;; stderr: [src/main.phel:2] w => 3 -;; => 12 +; => 12 ``` Because the value passes through, `dbg` works inside threading macros: diff --git a/content/documentation/guides/rosetta-stone.md b/content/documentation/guides/rosetta-stone.md index 9c2e54d..69bc515 100644 --- a/content/documentation/guides/rosetta-stone.md +++ b/content/documentation/guides/rosetta-stone.md @@ -2,7 +2,7 @@ title = "Rosetta Stone: PHP → Phel" weight = 3 description = "Side-by-side PHP and Phel for the same task: variables, functions, collections, strings, control flow, loops, and interop" -aliases = ["/documentation/rosetta-stone"] +aliases = ["/documentation/rosetta-stone", "/documentation/guides/phel-for-php-developers"] [extra] scripts = ["rosetta-stone.js"] diff --git a/content/documentation/installation.md b/content/documentation/installation.md index 0fc089c..5286fed 100644 --- a/content/documentation/installation.md +++ b/content/documentation/installation.md @@ -74,7 +74,7 @@ phel run src/main.phel | Variable | Default | Effect | |---|---|---| -| `PHEL_VERSION` | `latest` | Pin a Phel release, e.g. `0.50.0` | +| `PHEL_VERSION` | `latest` | Pin a Phel release, e.g. `0.54.0` | | `PHP_VERSION` | `8.5.8` | Pin a [static PHP build](https://dl.static-php.dev/static-php-cli/common/). Only the default has a pinned checksum | | `PREFIX` | `${TMPDIR}/phel-quick`, or `/tmp/phel-quick-$UID` | Where everything is downloaded | | `PHEL_FORCE` | `0` | `1` downloads again instead of reusing | diff --git a/content/documentation/language/basic-types.md b/content/documentation/language/basic-types.md index f8bfa83..ec84c7b 100644 --- a/content/documentation/language/basic-types.md +++ b/content/documentation/language/basic-types.md @@ -2,12 +2,15 @@ title = "Basic Types" weight = 1 description = "Phel's primitive values: nil, booleans, numbers, strings, keywords, plus truthiness, equality, and reader literals" -aliases = ["/documentation/basic-types", "/documentation/arithmetic", "/documentation/truth-and-boolean-operations"] +aliases = ["/documentation/basic-types", "/documentation/arithmetic", "/documentation/truth-and-boolean-operations", "/documentation/language/arithmetic", "/documentation/language/truth-and-boolean-operations", "/documentation/numbers-and-arithmetic"] [extra] difficulty = "beginner" +practice = ["first-steps"] +++ +Every Phel program is built from a few simple values: `nil`, booleans, numbers, strings, keywords and symbols. This page shows how to write each one, which values count as true, and how Phel compares them. + ## Nil, true, false ```phel diff --git a/content/documentation/language/control-flow.md b/content/documentation/language/control-flow.md index 1763a03..a6c2176 100644 --- a/content/documentation/language/control-flow.md +++ b/content/documentation/language/control-flow.md @@ -6,6 +6,7 @@ aliases = ["/documentation/control-flow"] [extra] difficulty = "beginner" +practice = ["decisions"] +++ Every form on this page is an expression: it returns a value. There are no statements. diff --git a/content/documentation/language/data-structures.md b/content/documentation/language/data-structures.md index 5ec0753..27a9315 100644 --- a/content/documentation/language/data-structures.md +++ b/content/documentation/language/data-structures.md @@ -6,6 +6,7 @@ aliases = ["/documentation/data-structures"] [extra] difficulty = "beginner" +practice = ["collections"] +++ All Phel collections are **persistent**: they never change. An update returns a new collection that shares most of its structure with the old one, so updates stay cheap and the original stays valid. @@ -202,7 +203,7 @@ A struct is a map with a fixed set of keys and a name. `defstruct` defines a con (point? p) ; => true (get p :x) ; => 1 (:y p) ; => 2 -(assoc p :x 10) ; => (point 10 2) +(assoc p :x 10) ; => (user.point 10 2) ``` A struct compiles to a PHP class with one property per key, so it is faster than a map. PHP code can call `count($p)` and read `$p['x']`: every struct implements `\Countable`, `\ArrayAccess`, and `\IteratorAggregate`. diff --git a/content/documentation/language/destructuring.md b/content/documentation/language/destructuring.md index f43a9d4..1ad7c81 100644 --- a/content/documentation/language/destructuring.md +++ b/content/documentation/language/destructuring.md @@ -6,6 +6,7 @@ aliases = ["/documentation/destructuring"] [extra] difficulty = "intermediate" +practice = ["shaping-data"] +++ Destructuring works everywhere Phel binds names: `let`, `defn` and `fn` parameters, `loop`, and `for`. diff --git a/content/documentation/language/error-handling.md b/content/documentation/language/error-handling.md index d89f5b9..9019c07 100644 --- a/content/documentation/language/error-handling.md +++ b/content/documentation/language/error-handling.md @@ -6,6 +6,7 @@ aliases = ["/documentation/error-handling", "/documentation/exceptions"] [extra] difficulty = "intermediate" +practice = ["state-and-errors"] +++ Phel uses PHP exceptions, so any PHP `Throwable` works with `throw` and `try`. diff --git a/content/documentation/language/functions-and-recursion.md b/content/documentation/language/functions-and-recursion.md index ca0a520..65e76ab 100644 --- a/content/documentation/language/functions-and-recursion.md +++ b/content/documentation/language/functions-and-recursion.md @@ -6,8 +6,11 @@ aliases = ["/documentation/functions-and-recursion"] [extra] difficulty = "intermediate" +practice = ["names-and-functions", "recursion-and-loops"] +++ +Functions are the main building block of a Phel program. This page shows how to define them with `defn` and `fn`, give one function several arities, accept any number of arguments, loop with `recur`, and choose an implementation at run time with multimethods. + ## Global functions diff --git a/content/documentation/language/global-and-local-bindings.md b/content/documentation/language/global-and-local-bindings.md index 2e76dea..7a6f108 100644 --- a/content/documentation/language/global-and-local-bindings.md +++ b/content/documentation/language/global-and-local-bindings.md @@ -6,8 +6,11 @@ aliases = ["/documentation/global-and-local-bindings"] [extra] difficulty = "beginner" +practice = ["names-and-functions"] +++ +A binding gives a value a name. Use `def` for a name the whole namespace sees, `let` for a name that exists only inside one expression, an atom for state that changes, and `binding` to give a dynamic var a new value for a while. + ## Definition (def) diff --git a/content/documentation/language/lazy-sequences.md b/content/documentation/language/lazy-sequences.md index 01a08d0..1a6cd91 100644 --- a/content/documentation/language/lazy-sequences.md +++ b/content/documentation/language/lazy-sequences.md @@ -6,6 +6,7 @@ aliases = ["/documentation/lazy-sequences"] [extra] difficulty = "advanced" +practice = ["sequences"] +++ A lazy sequence computes its values only when you read them. Use one for large or infinite sources, or when you read only part of the data. @@ -62,7 +63,7 @@ A lazy sequence can compute more values than you read. Side effects inside `map` ```phel (def xs (take 5 (map (fn [x] (println x) x) (range 100)))) -;; prints 0 through 5 when defined: six side effects for five results +; prints 0 through 5 when defined: six side effects for five results ``` Keep side effects out of lazy pipelines. Use `foreach` or `doseq`. diff --git a/content/documentation/language/macros.md b/content/documentation/language/macros.md index 1e69ed8..4523535 100644 --- a/content/documentation/language/macros.md +++ b/content/documentation/language/macros.md @@ -6,6 +6,7 @@ aliases = ["/documentation/macros"] [extra] difficulty = "advanced" +practice = ["macros"] +++ A macro runs at compile time. It receives code as data, rewrites it, and returns new code for the compiler. diff --git a/content/documentation/language/namespaces.md b/content/documentation/language/namespaces.md index 5d463bd..072e5fc 100644 --- a/content/documentation/language/namespaces.md +++ b/content/documentation/language/namespaces.md @@ -8,6 +8,8 @@ aliases = ["/documentation/namespaces"] difficulty = "intermediate" +++ +A namespace groups related definitions and keeps their names apart from other code. This page shows how to declare one, load other Phel modules and PHP classes, and shorten names with aliases and `:refer`. + ## Declare a namespace Every Phel file starts with `ns`. The name has parts separated by `.`, and the last part matches the file name. Use one namespace per file. Each part starts with a letter or underscore, followed by letters, numbers, underscores or dashes. Use kebab-case (`my.user-service`); the compiler turns dashes into a valid PHP namespace. diff --git a/content/documentation/language/php-interop.md b/content/documentation/language/php-interop.md index 61d74a3..ca49fe9 100644 --- a/content/documentation/language/php-interop.md +++ b/content/documentation/language/php-interop.md @@ -3,6 +3,10 @@ title = "PHP Interop" weight = 11 description = "Call PHP functions, create objects, convert PHP arrays, use Composer packages, and call Phel back from PHP." aliases = ["/documentation/php-interop/"] + +[extra] +difficulty = "intermediate" +practice = ["php-interop"] +++ Phel compiles to PHP, so every PHP function, class and Composer package is available. diff --git a/content/documentation/language/reader-shortcuts.md b/content/documentation/language/reader-shortcuts.md index 50782e1..3bbccef 100644 --- a/content/documentation/language/reader-shortcuts.md +++ b/content/documentation/language/reader-shortcuts.md @@ -1,7 +1,7 @@ +++ title = "Reader Shortcuts" weight = 8 -description = "Catalog of the Phel reader macros and special syntax: literals, quote/quasiquote, tagged literals, anonymous functions, reader conditionals for shared .cljc files, and metadata." +description = "Every Phel reader shortcut: literals, quote and quasiquote, tagged literals, anonymous functions, reader conditionals for .cljc files, and metadata." aliases = ["/documentation/language/reader-conditionals/"] [extra] diff --git a/content/documentation/reference/cli-commands.md b/content/documentation/reference/cli-commands.md index 28ecd4c..89e75b0 100644 --- a/content/documentation/reference/cli-commands.md +++ b/content/documentation/reference/cli-commands.md @@ -2,7 +2,7 @@ title = "CLI Commands" weight = 2 description = "Every built-in phel command with its flags and an example: init, run, test, build, eval, format, lint, repl, nrepl, lsp and more." -aliases = ["/documentation/cli-commands", "/documentation/tooling/cli-commands/"] +aliases = ["/documentation/cli-commands", "/documentation/tooling/cli-commands/", "/documentation/commands"] +++ Each command below shows its main flags and an example. `vendor/bin/phel help ` prints every flag of one command. diff --git a/content/documentation/reference/configuration.md b/content/documentation/reference/configuration.md index cb7127a..4b17c3c 100644 --- a/content/documentation/reference/configuration.md +++ b/content/documentation/reference/configuration.md @@ -5,6 +5,8 @@ description = "Every phel-config.php option with its default, the local override aliases = ["/documentation/configuration/"] +++ +Most projects run with the defaults. To change the source folders, the build output or other settings, edit `phel-config.php`. This page lists every option with its default, and the environment variables Phel reads. + ## Minimal config Phel reads `phel-config.php` from the project root. It returns a `PhelConfig`. Most projects need only the factory: diff --git a/content/documentation/reference/errors.md b/content/documentation/reference/errors.md index 1ecf122..e9f6371 100644 --- a/content/documentation/reference/errors.md +++ b/content/documentation/reference/errors.md @@ -4,7 +4,7 @@ weight = 4 description = "Every Phel error code (PHEL001-PHEL404), what it means, and how to fix it." +++ -> This page is the canonical index of error **codes**. To learn how to `throw`, `catch`, and attach data to errors in your own code, see [Error handling](/documentation/language/error-handling/). +This page lists every Phel error code. To learn how to `throw`, `catch`, and attach data to errors in your own code, see [Error handling](/documentation/language/error-handling/). Phel errors are tagged with a stable code like `[PHEL001]`. The code survives wording changes, so it is the reliable thing to search for. An error diff --git a/content/documentation/tooling/repl.md b/content/documentation/tooling/repl.md index fb0c4cd..bcbd3c8 100644 --- a/content/documentation/tooling/repl.md +++ b/content/documentation/tooling/repl.md @@ -2,9 +2,11 @@ title = "REPL" weight = 1 description = "Use the Phel REPL: history vars, doc/dir/apropos helpers, introspection, tap> debugging, and a REPL-driven workflow" -aliases = ["/documentation/repl", "/documentation/tooling/phel-helpers"] +aliases = ["/documentation/repl", "/documentation/tooling/phel-helpers", "/documentation/debug/phel-helpers"] +++ +The REPL runs Phel code as you type it and prints the result. Use it to try an idea, read the docs of a function, or check a value while you debug. + ## Start the REPL ```bash diff --git a/content/documentation/web/html-rendering.md b/content/documentation/web/html-rendering.md index a791264..8d7eb16 100644 --- a/content/documentation/web/html-rendering.md +++ b/content/documentation/web/html-rendering.md @@ -5,6 +5,8 @@ description = "Render HTML from Phel data structures: vectors are elements, maps aliases = ["/documentation/html-rendering"] +++ +`phel.html` builds HTML from plain Phel data. A vector is an element, a map holds its attributes, and text is escaped for you. + ## Elements and attributes `html` from `phel.html` turns a vector into an HTML string, with no template language. The first item is the tag (keyword or string), an optional map holds the attributes, and the rest is the body: strings, numbers, nested vectors, or lists of them. diff --git a/content/documentation/web/http-request-and-response.md b/content/documentation/web/http-request-and-response.md index c0f6c16..a42449d 100644 --- a/content/documentation/web/http-request-and-response.md +++ b/content/documentation/web/http-request-and-response.md @@ -5,6 +5,8 @@ description = "Read an HTTP request, build and emit a response, and test handler aliases = ["/documentation/http-request-and-response"] +++ +The `phel.http` namespace turns a PHP web request into Phel data and turns your response back into output. This page shows how to read a request, build and send a response, and test a handler without a web server. + ## The request-response cycle Everything lives in `phel.http`. A web entry point reads the request from PHP's globals, turns it into a response, and sends it with `emit-response` (once, at the end of the request): diff --git a/css/components/layout.css b/css/components/layout.css index 97e0b20..7de4d04 100644 --- a/css/components/layout.css +++ b/css/components/layout.css @@ -92,7 +92,6 @@ /* Sidebar content - collapsed by default on mobile */ .sidebar-content { display: none; - animation: fadeInUp var(--duration-fast) var(--ease-out); } .sidebar-content.active { @@ -128,7 +127,7 @@ background: var(--color-light-surface); border-radius: var(--radius-xl); box-shadow: var(--shadow-sm); - transition: all var(--duration-normal) var(--ease-out); + transition: box-shadow var(--duration-normal) var(--ease-out); } /* Remove the extra bottom border on desktop */ diff --git a/css/components/sidebar.css b/css/components/sidebar.css index 7f36d82..0f6f28d 100644 --- a/css/components/sidebar.css +++ b/css/components/sidebar.css @@ -43,7 +43,9 @@ font-weight: 500; border-radius: var(--radius-md); text-decoration: none; - transition: all var(--duration-fast) var(--ease-out); + transition: + color var(--duration-fast) var(--ease-out), + background-color var(--duration-fast) var(--ease-out); } .site-navigation__entry a::after { @@ -61,13 +63,9 @@ font-weight: 600; } -/* Sidebar section with toggle. Grid rows animate open/closed to the list's - real height, so a section with many pages is never clipped by a fixed - max-height cap. */ .sidebar-section { display: grid; grid-template-rows: auto 1fr; - transition: grid-template-rows var(--duration-normal, 0.25s) var(--ease-out); } .sidebar-section.collapsed { @@ -79,7 +77,7 @@ align-items: center; gap: var(--space-xs); border-radius: var(--radius-md); - transition: all var(--duration-fast) var(--ease-out); + transition: background-color var(--duration-fast) var(--ease-out); } .sidebar-section-header:hover { @@ -123,7 +121,9 @@ border-radius: var(--radius-sm); cursor: pointer; color: var(--color-light-text-secondary); - transition: all var(--duration-fast) var(--ease-out); + transition: + color var(--duration-fast) var(--ease-out), + border-color var(--duration-fast) var(--ease-out); padding: 0.3rem; margin: 0; margin-right: var(--space-sm); @@ -144,21 +144,12 @@ .sidebar-subsection { min-height: 0; overflow: hidden; - opacity: 1; visibility: visible; - transition: - opacity var(--duration-fast, 0.15s) var(--ease-out), - visibility 0s; } -/* visibility (not only opacity) takes the hidden links out of the tab - order; it flips after the collapse animation has finished. */ +/* Hidden links must also leave the tab order. */ .sidebar-section.collapsed .sidebar-subsection { - opacity: 0; visibility: hidden; - transition: - opacity var(--duration-fast, 0.15s) var(--ease-out), - visibility 0s linear var(--duration-normal, 0.25s); } .sidebar-section:not(.collapsed) .subsection-toggle svg { @@ -191,7 +182,9 @@ font-weight: 500; border-radius: var(--radius-md); text-decoration: none; - transition: all var(--duration-fast) var(--ease-out); + transition: + color var(--duration-fast) var(--ease-out), + background-color var(--duration-fast) var(--ease-out); } .site-navigation .sidebar-subsection li a::after { @@ -226,7 +219,9 @@ border-radius: var(--radius-md); text-decoration: none; user-select: none; - transition: all var(--duration-fast) var(--ease-out); + transition: + color var(--duration-fast) var(--ease-out), + background-color var(--duration-fast) var(--ease-out); } .site-navigation .sidebar-section-header a::after { diff --git a/package.json b/package.json index 859baaf..ef95b8a 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "generate-llms-full": "node ./scripts/generate-llms-full.js", "generate-doc-dates": "node ./scripts/generate-doc-dates.js", "check:links": "node ./scripts/check-internal-links.js", + "check:docs": "node ./scripts/check-docs.js", "prebuild-css": "node ./scripts/concat-tailwind.js && node ./scripts/generate-agent-doc.js && node ./scripts/generate-llms-full.js && node ./scripts/generate-doc-dates.js", "build-css": "npm run prebuild-css && postcss ./css/tailwind.entry.css -o ./static/tailwind.css --watch", "build-css-dev": "concurrently \"node ./scripts/concat-tailwind.js --watch\" \"postcss ./css/tailwind.entry.css -o ./static/tailwind.css --watch\" --names \"CONCAT,TAILWIND\" --prefix-colors \"yellow,cyan\"", diff --git a/scripts/check-docs.js b/scripts/check-docs.js new file mode 100644 index 0000000..07588f9 --- /dev/null +++ b/scripts/check-docs.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * Check the house rules of every documentation page. + * + * Reads: content/documentation/**.md (except the generated API reference), + * phel_version from config.toml + * Exits: 1 when a page breaks a rule + * + * Rules: a description of 50 to 160 characters, a difficulty on every language + * page, `; =>` and `; prints` comments, the callout component instead of a + * blockquote, no Phel release older than the current one outside upgrading.md, + * a lead paragraph before the first H2, and no H1 in the body. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); +const DOCS = path.join(ROOT, 'content', 'documentation'); +const GENERATED = path.join(DOCS, 'reference', 'api'); + +function currentMinor() { + const match = fs.readFileSync(path.join(ROOT, 'config.toml'), 'utf8').match(/^phel_version\s*=\s*"v?0\.(\d+)\.\d+"/m); + if (!match) throw new Error('config.toml has no phel_version'); + return Number(match[1]); +} + +function markdownFiles(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const abs = path.join(dir, entry.name); + if (abs === GENERATED) return []; + if (entry.isDirectory()) return markdownFiles(abs); + return entry.name.endsWith('.md') ? [abs] : []; + }); +} + +function split(source) { + const match = source.match(/^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+(?:\r?\n|$)/); + if (!match) return { frontMatter: '', body: source, bodyStart: 1 }; + return { frontMatter: match[1], body: source.slice(match[0].length), bodyStart: match[0].split('\n').length }; +} + +/** Body lines with their line number and whether they sit inside a code fence. */ +function lines(body, bodyStart) { + let fence = null; + return body.split('\n').map((text, i) => { + const marker = text.match(/^\s*(`{3,}|~{3,})(.*)$/); + const closes = + marker && fence !== null && marker[1][0] === fence[0] && marker[1].length >= fence.length && marker[2].trim() === ''; + const inCode = fence !== null || Boolean(marker); + if (closes) fence = null; + else if (marker && fence === null) fence = marker[1]; + return { text, line: bodyStart + i, inCode }; + }); +} + +function check(file, minor) { + const rel = path.relative(ROOT, file); + const { frontMatter, body, bodyStart } = split(fs.readFileSync(file, 'utf8')); + const problems = []; + const report = (line, message) => problems.push(`${rel}:${line} ${message}`); + + const description = frontMatter.match(/^description\s*=\s*"((?:[^"\\]|\\.)*)"/m); + if (!description) report(1, 'no description'); + else if (description[1].length < 50 || description[1].length > 160) { + report(1, `description is ${description[1].length} characters, keep it between 50 and 160`); + } + + const isLanguagePage = path.dirname(file) === path.join(DOCS, 'language') && path.basename(file) !== '_index.md'; + if (isLanguagePage && !/^difficulty\s*=/m.test(frontMatter)) report(1, 'no difficulty in [extra]'); + + const bodyLines = lines(body, bodyStart); + let seenLead = false; + let firstH2Checked = false; + for (const { text, line, inCode } of bodyLines) { + if (/;;\s*(=>|prints)/.test(text)) report(line, 'write `; =>` and `; prints`, with one semicolon'); + if (/phel/i.test(text) && rel !== path.join('content', 'documentation', 'reference', 'upgrading.md')) { + for (const [version, minorText] of text.matchAll(/(?/.test(text)) report(line, 'use {% %} instead of a blockquote'); + if (/^# /.test(text)) report(line, 'no H1 in the body: the template renders the title'); + if (/^## /.test(text) && !firstH2Checked) { + firstH2Checked = true; + if (!seenLead) report(line, 'add a lead paragraph before the first H2'); + } + if (text.trim() !== '' && !/^(#{1,6} |