diff --git a/AGENT.md b/AGENT.md index 53d6dc2..bc8ea1d 100644 --- a/AGENT.md +++ b/AGENT.md @@ -11,16 +11,17 @@ Decade-scale maintainability. Only essential tech. Readable, simple, future-proo 1. **Building on top** (`custom/`): Create sites using the framework. Never modify `app/`. In this mode, `app/` is typically symlinked or submoduled from the framework repo into a separate site repo. 2. **Framework development** (`app/`): Evolve the core. Preserve all stable contracts (see architecture doc). `custom/` may be symlinked in from a site repo for testing. -## Core Constraints +## Framework Development Workflow + +When modifying or adding code in `app/`: write tests and run them before marking work done. Read `docs/05-testing/01-testing.md` for the required workflow and test format. + +## Standards - **Stack:** HTML5, PHP 8.4+, CSS. Nothing else. -- **Frontend:** Classless semantic HTML, modern CSS (nesting, `oklch()`, grid, `clamp()`, logical props) - **Security:** Path traversal protection, document root restriction, strict MIME types, escape all UGC - -## Code Style - -- **PHP:** Arrow functions, null coalescing, match expressions. Type hints where practical. Single-purpose functions. Comments only for major sections. -- **CSS:** Variables, native nesting, grid. `clamp()` over `@media`. Relative units. +- **PHP:** Arrow functions, null coalescing, match expressions. Type hints where practical. Single-purpose functions, avoid side effects. Comments only for major sections. +- **HTML:** Classless, semantic markup. +- **CSS:** Variables, native nesting, relative units. Modern CSS (`oklch()`, `light-dark()`, `clamp()`, logical props). Global styles first, page-scoped when needed. - **Templates:** `` for UGC. `` for pre-rendered HTML. ## Knowledge Base @@ -37,5 +38,6 @@ Read these docs on-demand when working on related areas. Do not load all at once | Templates | `docs/04-development/06-templates.md` | Template hierarchy, resolution, variables, list item schema, partials | | Rendering | `docs/04-development/07-rendering.md` | Rendering pipeline, Markdown caching, static file serving, Parsedown | | Dev Environment | `docs/04-development/08-dev-environment.md` | Container setup, Apache config, performance profiling, test data generation | +| Testing | `docs/05-testing/01-testing.md` | **Required before modifying `app/`** — workflow, test format, what's testable | Human-facing docs (tutorials, reference) are in `docs/01-getting-started/`, `docs/02-tutorial/`, `docs/03-reference/`. diff --git a/devel/compose.yaml b/devel/compose.yaml index 506f507..947818c 100644 --- a/devel/compose.yaml +++ b/devel/compose.yaml @@ -9,6 +9,7 @@ services: - ../app:/var/www/app:z - ./apache/custom.conf:/etc/apache2/conf-available/custom.conf:z - ./apache/default.conf:/etc/apache2/sites-available/000-default.conf:z + - ./tests:/var/www/tests:z ports: - "8080:80" command: bash -c "a2enconf custom && a2enmod rewrite && apache2-foreground" diff --git a/devel/tests/cache/build_cache_key.phpt b/devel/tests/cache/build_cache_key.phpt new file mode 100644 index 0000000..a85ca4b --- /dev/null +++ b/devel/tests/cache/build_cache_key.phpt @@ -0,0 +1,49 @@ +--TEST-- +buildCacheKey: returns md5 string, varies by path and langPrefix +--FILE-- + +--EXPECT-- +valid md5 +stable +prefix differs +path differs +meta invalidates diff --git a/devel/tests/cache/get_set_cached_markdown.phpt b/devel/tests/cache/get_set_cached_markdown.phpt new file mode 100644 index 0000000..8239c03 --- /dev/null +++ b/devel/tests/cache/get_set_cached_markdown.phpt @@ -0,0 +1,36 @@ +--TEST-- +getCachedMarkdown / setCachedMarkdown: store and retrieve rendered HTML by file key +--FILE-- +Hello'); +echo getCachedMarkdown($file) . "\n"; + +// langPrefix produces a separate cache entry +echo (getCachedMarkdown($file, '/fr') ?? 'null') . "\n"; + +setCachedMarkdown($file, '

Bonjour

', '/fr'); +echo getCachedMarkdown($file, '/fr') . "\n"; + +// Default key is unaffected +echo getCachedMarkdown($file) . "\n"; + +unlink($file); +rmdir($dir); +?> +--EXPECT-- +null +

Hello

+null +

Bonjour

+

Hello

diff --git a/devel/tests/content/find_all_content_files.phpt b/devel/tests/content/find_all_content_files.phpt new file mode 100644 index 0000000..639a60a --- /dev/null +++ b/devel/tests/content/find_all_content_files.phpt @@ -0,0 +1,47 @@ +--TEST-- +findAllContentFiles: returns sorted content file paths, ignores non-content files and index.php +--FILE-- +Hi

'); +file_put_contents("$dir/script.php", ' +--EXPECT-- +0 +0 +3 +extra.html +page.md +script.php diff --git a/devel/tests/content/load_metadata.phpt b/devel/tests/content/load_metadata.phpt new file mode 100644 index 0000000..3cd6bce --- /dev/null +++ b/devel/tests/content/load_metadata.phpt @@ -0,0 +1,44 @@ +--TEST-- +loadMetadata: parses metadata.ini, returns null when absent +--FILE-- + +--EXPECT-- +null +My Page +A summary +1 +raw present +section contained +English Title +French Title diff --git a/devel/tests/content/resolve_slug_to_folder.phpt b/devel/tests/content/resolve_slug_to_folder.phpt new file mode 100644 index 0000000..8596f00 --- /dev/null +++ b/devel/tests/content/resolve_slug_to_folder.phpt @@ -0,0 +1,48 @@ +--TEST-- +resolveSlugToFolder: resolves URL slug to actual folder name, supports metadata slug override +--FILE-- + +--EXPECT-- +null +my-post +null +2024-01-15-article +2024-01-15-article +my-post diff --git a/devel/tests/context/context_data.phpt b/devel/tests/context/context_data.phpt new file mode 100644 index 0000000..7d088e3 --- /dev/null +++ b/devel/tests/context/context_data.phpt @@ -0,0 +1,54 @@ +--TEST-- +Context: set/get/has and magic property access for plugin data storage +--FILE-- +requestPath . "\n"; +echo $ctx->contentDir . "\n"; +echo ($ctx->hasTrailingSlash ? 'true' : 'false') . "\n"; + +// set/get/has +$ctx->set('langPrefix', '/fr'); +echo $ctx->get('langPrefix') . "\n"; +echo ($ctx->has('langPrefix') ? 'yes' : 'no') . "\n"; +echo ($ctx->has('missing') ? 'yes' : 'no') . "\n"; + +// get with default +echo $ctx->get('nope', 'fallback') . "\n"; + +// Magic __get / __set +$ctx->customKey = 'magic-value'; +echo $ctx->customKey . "\n"; + +// Magic __get for undefined key returns null +echo ($ctx->undefined ?? 'null') . "\n"; + +// set and magic access are the same store +$ctx->set('shared', 'data'); +echo $ctx->shared . "\n"; + +$ctx->magic2 = 'x'; +echo $ctx->get('magic2') . "\n"; +?> +--EXPECT-- +blog/post +/tmp/content +true +/fr +yes +no +fallback +magic-value +null +data +x diff --git a/devel/tests/helpers/extract_meta_description.phpt b/devel/tests/helpers/extract_meta_description.phpt new file mode 100644 index 0000000..9e02756 --- /dev/null +++ b/devel/tests/helpers/extract_meta_description.phpt @@ -0,0 +1,45 @@ +--TEST-- +extractMetaDescription: returns description from metadata fields or first content paragraph +--FILE-- + 'SEO description', 'summary' => 'Summary']) . "\n"; + +// summary is fallback when no search_description +echo extractMetaDescription($dir, ['summary' => 'Page summary']) . "\n"; + +// null metadata, no content files +echo (extractMetaDescription($dir, null) ?? 'null') . "\n"; + +// First paragraph from markdown (skips headings and short lines) +file_put_contents("$dir/index.md", "# Title\n\nThis is the opening paragraph of the post.\n\nSecond paragraph."); +echo extractMetaDescription($dir, null) . "\n"; +unlink("$dir/index.md"); + +// Heading-only markdown with no usable paragraph +file_put_contents("$dir/index.md", "# Just a Heading\n\nShort.\n"); +echo (extractMetaDescription($dir, null) ?? 'null') . "\n"; +unlink("$dir/index.md"); + +// First

from HTML +file_put_contents("$dir/index.html", "

Title

HTML paragraph text here.

"); +echo extractMetaDescription($dir, null) . "\n"; +unlink("$dir/index.html"); + +rmdir($dir); +?> +--EXPECT-- +SEO description +Page summary +null +This is the opening paragraph of the post. +null +HTML paragraph text here. diff --git a/devel/tests/helpers/extract_raw_date_from_folder.phpt b/devel/tests/helpers/extract_raw_date_from_folder.phpt new file mode 100644 index 0000000..1e12530 --- /dev/null +++ b/devel/tests/helpers/extract_raw_date_from_folder.phpt @@ -0,0 +1,20 @@ +--TEST-- +extractRawDateFromFolder: extracts YYYY-MM-DD from date-prefixed folder names +--FILE-- + +--EXPECT-- +2024-01-15 +2024-12-31 +null +null +null +null diff --git a/devel/tests/helpers/extract_title.phpt b/devel/tests/helpers/extract_title.phpt new file mode 100644 index 0000000..c59ef4e --- /dev/null +++ b/devel/tests/helpers/extract_title.phpt @@ -0,0 +1,49 @@ +--TEST-- +extractTitle: extracts title from # heading in .md or

in .html +--FILE-- + +file_put_contents("$dir/index.html", "

HTML Title

Body

"); +echo extractTitle($dir) . "\n"; +unlink("$dir/index.html"); + +// HTML with attributes on h1 +file_put_contents("$dir/index.html", '

Styled Title

'); +echo extractTitle($dir) . "\n"; +unlink("$dir/index.html"); + +// Markdown without heading +file_put_contents("$dir/index.md", "Just a paragraph, no heading."); +echo (extractTitle($dir) ?? 'null') . "\n"; +unlink("$dir/index.md"); + +rmdir($dir); +?> +--EXPECT-- +null +My Great Post +Spaced Title +HTML Title +Styled Title +null diff --git a/devel/tests/helpers/find_cover_image.phpt b/devel/tests/helpers/find_cover_image.phpt new file mode 100644 index 0000000..e9263c7 --- /dev/null +++ b/devel/tests/helpers/find_cover_image.phpt @@ -0,0 +1,33 @@ +--TEST-- +findCoverImage: finds cover.{ext} in directory, prefers first matching extension +--FILE-- + +--EXPECT-- +null +cover.jpg +cover.jpg +cover.webp diff --git a/devel/tests/helpers/find_page_css.phpt b/devel/tests/helpers/find_page_css.phpt new file mode 100644 index 0000000..80cb75d --- /dev/null +++ b/devel/tests/helpers/find_page_css.phpt @@ -0,0 +1,42 @@ +--TEST-- +findPageCss: returns URL and hash for styles.css, null when absent +--FILE-- + +--EXPECT-- +null +/blog/post/styles.css +hash ok +/styles.css +null diff --git a/devel/tests/helpers/find_page_js.phpt b/devel/tests/helpers/find_page_js.phpt new file mode 100644 index 0000000..f126a49 --- /dev/null +++ b/devel/tests/helpers/find_page_js.phpt @@ -0,0 +1,36 @@ +--TEST-- +findPageJs: returns URL and hash for script.js, null when absent +--FILE-- + +--EXPECT-- +null +/section/page/script.js +hash ok +/script.js diff --git a/devel/tests/helpers/find_pdf_file.phpt b/devel/tests/helpers/find_pdf_file.phpt new file mode 100644 index 0000000..2f923d8 --- /dev/null +++ b/devel/tests/helpers/find_pdf_file.phpt @@ -0,0 +1,28 @@ +--TEST-- +findPdfFile: finds first .pdf file in a directory +--FILE-- + +--EXPECT-- +null +document.pdf +document.pdf diff --git a/devel/tests/helpers/get_subdirectories.phpt b/devel/tests/helpers/get_subdirectories.phpt new file mode 100644 index 0000000..4de57a2 --- /dev/null +++ b/devel/tests/helpers/get_subdirectories.phpt @@ -0,0 +1,39 @@ +--TEST-- +getSubdirectories: returns only subdirectory names, ignores files +--FILE-- + +--EXPECT-- +empty +0 +alpha +beta +file excluded diff --git a/devel/tests/hooks/hooks_add_apply.phpt b/devel/tests/hooks/hooks_add_apply.phpt new file mode 100644 index 0000000..8ca291d --- /dev/null +++ b/devel/tests/hooks/hooks_add_apply.phpt @@ -0,0 +1,38 @@ +--TEST-- +Hooks: apply passes value through, add registers filters that chain in order +--FILE-- + strtoupper($v)); +echo Hooks::apply(Hook::PROCESS_CONTENT, 'hello') . "\n"; + +// Filters chain: second receives output of first +Hooks::add(Hook::PROCESS_CONTENT, fn($v) => $v . '!'); +echo Hooks::apply(Hook::PROCESS_CONTENT, 'hello') . "\n"; + +// Other hooks are unaffected +echo Hooks::apply(Hook::TEMPLATE_VARS, 'foo') . "\n"; + +// Extra args are passed through to each filter +Hooks::add(Hook::TEMPLATE_VARS, fn($v, $extra) => "$v:$extra"); +echo Hooks::apply(Hook::TEMPLATE_VARS, 'x', 'ctx') . "\n"; + +// Non-string values pass through too +$arr = ['a' => 1]; +echo Hooks::apply(Hook::CONTEXT_READY, $arr) === $arr ? 'array passthrough' : 'fail'; +echo "\n"; +?> +--EXPECT-- +hello +foo +HELLO +HELLO! +foo +x:ctx +array passthrough diff --git a/devel/tests/plugins/plugin_manager.phpt b/devel/tests/plugins/plugin_manager.phpt new file mode 100644 index 0000000..36e55c9 --- /dev/null +++ b/devel/tests/plugins/plugin_manager.phpt @@ -0,0 +1,59 @@ +--TEST-- +PluginManager: tracks loaded plugins, deduplicate loads, respects scope +--FILE-- +getLoadedPlugins()) . "\n"; +echo count($pm->getGlobalPlugins()) . "\n"; +echo ($pm->isLoaded('languages') ? 'yes' : 'no') . "\n"; +echo ($pm->getPluginInfo('languages') ?? 'null') . "\n"; + +// loadPagePlugins with null — no-op +$pm->loadPagePlugins(null); +echo count($pm->getLoadedPlugins()) . "\n"; + +// loadPagePlugins with no plugins key — no-op +$pm->loadPagePlugins(['title' => 'My Page']); +echo count($pm->getLoadedPlugins()) . "\n"; + +// loadGlobalPlugins with no enabled key — no-op +$pm->loadGlobalPlugins(['other' => 'stuff']); +echo count($pm->getLoadedPlugins()) . "\n"; + +// Load the built-in languages plugin via global config +$pm->loadGlobalPlugins(['plugins' => ['enabled' => 'languages']]); +echo ($pm->isLoaded('languages') ? 'yes' : 'no') . "\n"; +echo count($pm->getGlobalPlugins()) . "\n"; +echo in_array('languages', $pm->getGlobalPlugins()) ? 'in global' : 'not in global'; +echo "\n"; + +// Loading again is a no-op (deduplication) +$pm->loadGlobalPlugins(['plugins' => ['enabled' => 'languages']]); +echo count($pm->getLoadedPlugins()) . "\n"; + +// getPluginInfo returns path and scope +$info = $pm->getPluginInfo('languages'); +echo ($info['scope'] === 'global' ? 'scope ok' : 'scope wrong') . "\n"; +echo (str_ends_with($info['path'], 'languages.php') ? 'path ok' : 'path wrong') . "\n"; + +?> +--EXPECT-- +0 +0 +no +null +0 +0 +0 +yes +1 +in global +1 +scope ok +path ok diff --git a/devel/tests/rendering/render_content_file.phpt b/devel/tests/rendering/render_content_file.phpt new file mode 100644 index 0000000..e740750 --- /dev/null +++ b/devel/tests/rendering/render_content_file.phpt @@ -0,0 +1,39 @@ +--TEST-- +renderContentFile: renders .html by inclusion and .md via Parsedown +--FILE-- +Hello world

'); +echo trim(renderContentFile($htmlFile)) . "\n"; + +// Markdown — converted to HTML by Parsedown +$mdFile = "$dir/post.md"; +file_put_contents($mdFile, "# My Title\n\nA paragraph."); +$html = renderContentFile($mdFile); +echo (str_contains($html, '

My Title

') ? 'h1 ok' : 'h1 missing') . "\n"; +echo (str_contains($html, '

A paragraph.

') ? 'p ok' : 'p missing') . "\n"; + +// Unknown extension — returns empty string +$txtFile = "$dir/notes.txt"; +file_put_contents($txtFile, 'raw text'); +echo (renderContentFile($txtFile) === '') ? 'empty ok' : 'not empty'; +echo "\n"; + +unlink($htmlFile); +unlink($mdFile); +unlink($txtFile); +rmdir($dir); +?> +--EXPECT-- +

Hello world

+h1 ok +p ok +empty ok diff --git a/devel/tests/run.php b/devel/tests/run.php new file mode 100644 index 0000000..f3fca6d --- /dev/null +++ b/devel/tests/run.php @@ -0,0 +1,112 @@ +getExtension() === 'phpt') { + $files[] = $file->getPathname(); + } +} +sort($files); + +$pass = 0; +$fail = 0; +$skip = 0; + +foreach ($files as $file) { + $rel = ltrim(substr($file, strlen(__DIR__), -5), '/'); + + if ($filter !== null && !str_contains($rel, $filter)) { + continue; + } + + $sections = parse_phpt(file_get_contents($file)); + $title = trim($sections['TEST'] ?? $rel); + + if (!isset($sections['FILE']) || !isset($sections['EXPECT'])) { + echo RED . 'INVALID' . RESET . " $name (missing --FILE-- or --EXPECT--)\n"; + $fail++; + continue; + } + + if (isset($sections['SKIPIF'])) { + $result = trim(run_php($sections['SKIPIF'])); + if (str_starts_with($result, 'skip')) { + $reason = ltrim(substr($result, 4)); + echo YELLOW . 'SKIP' . RESET . " $title" . ($reason ? " — $reason" : '') . "\n"; + $skip++; + continue; + } + } + + $actual = trim(run_php($sections['FILE'])); + $expected = trim($sections['EXPECT']); + + if ($actual === $expected) { + echo GREEN . 'PASS' . RESET . " $title\n"; + $pass++; + } else { + echo RED . 'FAIL' . RESET . " $title\n"; + show_diff($expected, $actual); + $fail++; + } +} + +$total = $pass + $fail + $skip; +echo "\n" . BOLD . "Results: $pass/$total passed" . ($skip ? ", $skip skipped" : '') . RESET . "\n"; +exit($fail > 0 ? 1 : 0); + +// ---- helpers ---- + +function parse_phpt(string $content): array +{ + $sections = []; + $current = null; + foreach (explode("\n", $content) as $line) { + if (preg_match('/^--([A-Z]+)--$/', rtrim($line), $m)) { + $current = $m[1]; + $sections[$current] = ''; + } elseif ($current !== null) { + $sections[$current] .= $line . "\n"; + } + } + return $sections; +} + +function run_php(string $code): string +{ + $tmp = tempnam('/tmp', 'phpt_'); + file_put_contents($tmp, $code); + $output = shell_exec("php $tmp 2>&1"); + unlink($tmp); + return $output ?? ''; +} + +function show_diff(string $expected, string $actual): void +{ + $exp = explode("\n", $expected); + $act = explode("\n", $actual); + $max = max(count($exp), count($act)); + for ($i = 0; $i < $max; $i++) { + $e = $exp[$i] ?? null; + $a = $act[$i] ?? null; + if ($e === $a) { + echo " $e\n"; + } else { + if ($e !== null) echo RED . " - $e" . RESET . "\n"; + if ($a !== null) echo GREEN . " + $a" . RESET . "\n"; + } + } +} diff --git a/devel/tests/run.sh b/devel/tests/run.sh new file mode 100755 index 0000000..6fed8b1 --- /dev/null +++ b/devel/tests/run.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Run phpt tests inside the container. +# Usage: ./run.sh [filter] — filter matches against test filename substrings +# Example: ./run.sh extract_raw_date + +CONTAINER=${CONTAINER:-folderweb-default} + +podman exec "$CONTAINER" php /var/www/tests/run.php "$@" diff --git a/docs/05-testing/01-testing.md b/docs/05-testing/01-testing.md new file mode 100644 index 0000000..c5ceab3 --- /dev/null +++ b/docs/05-testing/01-testing.md @@ -0,0 +1,73 @@ +# Testing + +## Workflow + +When modifying or adding functions in `app/`, follow this sequence — **a task is not complete until all steps are done**: + +1. **Write or update the code** in the relevant `app/*.php` file. +2. **Write tests** in `devel/tests//` matching the changed file (e.g. `app/content.php` → `devel/tests/content/`). +3. **Run the tests:** `cd devel && ./tests/run.sh` +4. **Fix any failures** — do not consider the task done while tests are red. + +When tests already exist for the changed function, update them to cover the new behaviour. + +**Not unit-testable** (skip for these): `router.php`, `static.php`, template files, full HTTP flows. + +## Framework Tests (`app/`) + +Unit tests live in `devel/tests/` and use the [phpt format](https://php.github.io/php-src/miscellaneous/writing-tests.html) ([lessons learned](https://wiki.php.net/qa/phptlessonslearned)). The runner is a minimal PHP script — no test framework, no Composer. Tests cover `app/` only. + +### Running + +```bash +cd devel + +./tests/run.sh # Run all tests +./tests/run.sh helpers # Filter by subdir or filename substring +``` + +`CONTAINER=my-container ./tests/run.sh` to override the container name. Exit code `1` on any failure — CI-compatible. Container must be running (`podman-compose up -d`). + +### File Layout + +``` +devel/tests/ + run.php # Runner — executed inside the container + run.sh # Host entry point (calls podman exec) + helpers/ # Tests for app/helpers.php + content/ # Tests for app/content.php + config/ # Tests for app/config.php + ... # One subdir per app/ module +``` + +### Writing Tests + +App files are at `/var/www/app/` inside the container. Require only what the function under test needs. + +``` +--TEST-- +extractRawDateFromFolder: extracts YYYY-MM-DD from date-prefixed folder names +--FILE-- + +--EXPECT-- +2024-01-15 +``` + +Filesystem tests: use `sys_get_temp_dir() . '/phpt_' . getmypid()` and clean up in `--FILE--`. + +### What to Test + +| Test | How | +|---|---| +| Pure functions | Direct call + echo | +| Filesystem functions | Temp dir, clean up after | +| Config merging, metadata parsing | Temp files or inline strings | +| Full HTTP flow | Browser or `perf.sh` — not unit-tested | +| Template rendering | Not unit-tested | + +## Site Tests (`custom/`) + +For sites built on top of the framework, [Pest](https://github.com/pestphp/pest) is a good fit — it supports browser testing via Playwright, useful for end-to-end testing of custom templates, plugins, and content. Pest is not used in this repo.