diff --git a/app/default/templates/base.php b/app/default/templates/base.php index ce25bfd..d739dbe 100644 --- a/app/default/templates/base.php +++ b/app/default/templates/base.php @@ -54,5 +54,8 @@

+ + + diff --git a/app/helpers.php b/app/helpers.php index 489202e..c9c722c 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -132,6 +132,22 @@ function findPageCss(string $dirPath, string $contentDir): ?array { ]; } +function findPageJs(string $dirPath, string $contentDir): ?array { + $jsFile = "$dirPath/script.js"; + if (!file_exists($jsFile) || !is_file($jsFile)) { + return null; + } + + $relativePath = str_replace($contentDir, '', $dirPath); + $relativePath = trim($relativePath, '/'); + $jsUrl = '/' . ($relativePath ? $relativePath . '/' : '') . 'script.js'; + + return [ + 'url' => $jsUrl, + 'hash' => hash_file('md5', $jsFile) + ]; +} + function extractMetaDescription(string $dirPath, ?array $metadata): ?string { // 1. Check for search_description in metadata if ($metadata && isset($metadata['search_description'])) { diff --git a/app/rendering.php b/app/rendering.php index ef4e57a..c4a099a 100644 --- a/app/rendering.php +++ b/app/rendering.php @@ -57,6 +57,8 @@ function renderTemplate(Context $ctx, string $content, int $statusCode = 200): v 'metaDescription' => $ctx->get('metaDescription'), 'pageCssUrl' => $ctx->get('pageCssUrl'), 'pageCssHash' => $ctx->get('pageCssHash'), + 'pageJsUrl' => $ctx->get('pageJsUrl'), + 'pageJsHash' => $ctx->get('pageJsHash'), 'feedUrl' => $ctx->get('feedUrl') ], $ctx); @@ -88,6 +90,10 @@ function renderMultipleFiles(Context $ctx, array $files, string $pageDir): void $pageCssUrl = $pageCss['url'] ?? null; $pageCssHash = $pageCss['hash'] ?? null; + $pageJs = findPageJs($pageDir, $ctx->contentDir); + $pageJsUrl = $pageJs['url'] ?? null; + $pageJsHash = $pageJs['hash'] ?? null; + $coverImage = findCoverImage($pageDir); $socialImageUrl = null; if ($coverImage) { @@ -104,6 +110,8 @@ function renderMultipleFiles(Context $ctx, array $files, string $pageDir): void 'metaDescription' => $metaDescription, 'pageCssUrl' => $pageCssUrl, 'pageCssHash' => $pageCssHash, + 'pageJsUrl' => $pageJsUrl, + 'pageJsHash' => $pageJsHash, 'socialImageUrl' => $socialImageUrl ], $ctx); diff --git a/app/router.php b/app/router.php index d9710e0..9772872 100644 --- a/app/router.php +++ b/app/router.php @@ -42,6 +42,7 @@ if (file_exists($contentAssetPath) && is_file($contentAssetPath)) { 'woff2' => 'font/woff2', 'ttf' => 'font/ttf', 'otf' => 'font/otf', + 'js' => 'application/javascript', ]; $extLower = strtolower($ext); @@ -205,10 +206,14 @@ switch ($parsedPath['type']) { $pageTitle = $metadata['title'] ?? null; $metaDescription = extractMetaDescription($dir, $metadata); - // Check for page-specific CSS + // Check for page-specific CSS and JS $pageCss = findPageCss($dir, $ctx->contentDir); $pageCssUrl = $pageCss['url'] ?? null; $pageCssHash = $pageCss['hash'] ?? null; + + $pageJs = findPageJs($dir, $ctx->contentDir); + $pageJsUrl = $pageJs['url'] ?? null; + $pageJsHash = $pageJs['hash'] ?? null; // Build feed URL if feed is enabled $langPrefix = $ctx->get('langPrefix', ''); @@ -221,6 +226,8 @@ switch ($parsedPath['type']) { $ctx->set('metaDescription', $metaDescription); $ctx->set('pageCssUrl', $pageCssUrl); $ctx->set('pageCssHash', $pageCssHash); + $ctx->set('pageJsUrl', $pageJsUrl); + $ctx->set('pageJsHash', $pageJsHash); $ctx->set('feedUrl', $feedUrl); // Let plugins add template variables @@ -231,6 +238,8 @@ switch ($parsedPath['type']) { 'metaDescription' => $metaDescription, 'pageCssUrl' => $pageCssUrl, 'pageCssHash' => $pageCssHash, + 'pageJsUrl' => $pageJsUrl, + 'pageJsHash' => $pageJsHash, 'items' => $items, 'pageContent' => $pageContent, 'feedUrl' => $feedUrl diff --git a/docs/02-tutorial/02-styling.md b/docs/02-tutorial/02-styling.md index 0aa78ba..1d4b064 100644 --- a/docs/02-tutorial/02-styling.md +++ b/docs/02-tutorial/02-styling.md @@ -241,6 +241,33 @@ FolderWeb automatically loads and includes page-specific styles with cache-busti ``` +## Page-Specific Scripts + +For small progressive enhancements, you can add a `script.js` file to a content directory: + +``` +content/portfolio/ +├── index.md +├── styles.css +└── script.js +``` + +**script.js:** +```js +// Small enhancement for this page only +document.querySelector('.portfolio-grid')?.addEventListener('click', (e) => { + // ... +}); +``` + +FolderWeb automatically loads the script with `defer` (non-blocking) and cache-busting: + +```html + +``` + +The script tag is placed before ``, so it runs after the page has been parsed. This is ideal for progressive enhancement — the page works without JavaScript, but gets enhanced when it's available. + ## Dark Mode Add dark mode with CSS variables and `prefers-color-scheme`: diff --git a/docs/02-tutorial/03-templates.md b/docs/02-tutorial/03-templates.md index 5e14654..cc7d9eb 100644 --- a/docs/02-tutorial/03-templates.md +++ b/docs/02-tutorial/03-templates.md @@ -128,6 +128,10 @@ Let's modify `base.php` to add your site name and custom navigation: Generated in s

+ + + + ``` @@ -301,6 +305,7 @@ $languageUrls // Links to other language versions $translations // Translated UI strings $cssHash // Cache-busting hash for CSS $pageCssUrl // Page-specific CSS URL (if exists) +$pageJsUrl // Page-specific JS URL (if exists) $pageLoadTime // Page generation time ``` diff --git a/docs/03-reference/03-template-variables.md b/docs/03-reference/03-template-variables.md index 9bef00b..47ea737 100644 --- a/docs/03-reference/03-template-variables.md +++ b/docs/03-reference/03-template-variables.md @@ -211,6 +211,27 @@ MD5 hash of page-specific CSS for cache busting. **Optional:** Only set if `$pageCssUrl` exists **Example:** See `$pageCssUrl` above +### `$pageJsUrl` + +URL to page-specific JavaScript file. + +**Type:** String (URL) +**Optional:** Only set if `script.js` exists in content directory +**Example:** +```php + + + +``` + +### `$pageJsHash` + +MD5 hash of page-specific JavaScript for cache busting. + +**Type:** String (MD5 hash) +**Optional:** Only set if `$pageJsUrl` exists +**Example:** See `$pageJsUrl` above + ### `$feedUrl` URL to the Atom feed for the current list page. @@ -415,6 +436,8 @@ Then use in templates: | `$translations` | ✓ | — | — | | `$pageCssUrl` | ✓ | — | — | | `$pageCssHash` | ✓ | — | — | +| `$pageJsUrl` | ✓ | — | — | +| `$pageJsHash` | ✓ | — | — | | `$feedUrl` | ✓ | — | — | | `$metadata` | — | ✓ | ✓ | | `$pageContent` | — | — | ✓ | diff --git a/docs/04-development/02-content-system.md b/docs/04-development/02-content-system.md index c0d6970..b821dbb 100644 --- a/docs/04-development/02-content-system.md +++ b/docs/04-development/02-content-system.md @@ -126,6 +126,12 @@ Checks extensions in `COVER_IMAGE_EXTENSIONS` order: `jpg`, `jpeg`, `png`, `webp Returns `['url' => string, 'hash' => string]` or null. Hash is MD5 of file content for cache busting. +## Page-Specific JavaScript + +**`findPageJs(string $dirPath, string $contentDir): ?array`** — Checks for `script.js` in content directory. + +Returns `['url' => string, 'hash' => string]` or null. Hash is MD5 of file content for cache busting. The script is loaded with the `defer` attribute in `base.php`, placed before `` for non-blocking progressive enhancement. + ## Meta Description Extraction **`extractMetaDescription(string $dirPath, ?array $metadata): ?string`** diff --git a/docs/04-development/06-templates.md b/docs/04-development/06-templates.md index 5c2a512..e6af526 100644 --- a/docs/04-development/06-templates.md +++ b/docs/04-development/06-templates.md @@ -59,6 +59,8 @@ Variables are injected via `extract()` — each array key becomes a local variab | `$homeLabel` | string | yes | Home link text | | `$pageCssUrl` | ?string | no | Page-specific CSS URL | | `$pageCssHash` | ?string | no | CSS cache-bust hash | +| `$pageJsUrl` | ?string | no | Page-specific JS URL | +| `$pageJsHash` | ?string | no | JS cache-bust hash | | `$feedUrl` | ?string | no | Atom feed URL (only on lists with `feed = true`) | | `$currentLang` | string | plugin | Language code (from languages plugin) | | `$langPrefix` | string | plugin | URL language prefix | diff --git a/docs/04-development/07-rendering.md b/docs/04-development/07-rendering.md index 721f676..b2b49d8 100644 --- a/docs/04-development/07-rendering.md +++ b/docs/04-development/07-rendering.md @@ -23,7 +23,7 @@ Used for both frontpage and page views: 1. Load metadata for `$pageDir` 2. Load page plugins (from metadata `plugins` field) 3. Render all content files, concatenate HTML -4. Compute: `$pageTitle`, `$metaDescription`, `$pageCssUrl`/`$pageCssHash`, `$socialImageUrl` +4. Compute: `$pageTitle`, `$metaDescription`, `$pageCssUrl`/`$pageCssHash`, `$pageJsUrl`/`$pageJsHash`, `$socialImageUrl` 5. Fire `Hook::TEMPLATE_VARS` with all variables 6. `extract()` variables → render `page.php` → capture output as `$content` 7. Render `base.php` with `$content` + base variables @@ -37,12 +37,12 @@ Handled directly in `router.php` (not a separate function): 2. Load metadata, check `hide_list` 3. Select list template from `page_template` metadata 4. Call `buildListItems()` from `helpers.php` (builds + sorts items) -5. Store `pageTitle`, `metaDescription`, `pageCssUrl`, `pageCssHash`, `feedUrl` on context +5. Store `pageTitle`, `metaDescription`, `pageCssUrl`, `pageCssHash`, `pageJsUrl`, `pageJsHash`, `feedUrl` on context 6. Fire `Hook::TEMPLATE_VARS` 7. Render list template → capture as `$content` 8. Pass to `renderTemplate()` which renders `base.php` -**`renderTemplate(Context $ctx, string $content, int $statusCode = 200): void`** — Wraps content in base template. Used for list views and error pages. Reads `pageTitle`, `metaDescription`, `pageCssUrl`, `pageCssHash`, and `feedUrl` from the context object (set by the list case in `router.php`). For error pages, these context keys are unset, so base.php receives nulls. +**`renderTemplate(Context $ctx, string $content, int $statusCode = 200): void`** — Wraps content in base template. Used for list views and error pages. Reads `pageTitle`, `metaDescription`, `pageCssUrl`, `pageCssHash`, `pageJsUrl`, `pageJsHash`, and `feedUrl` from the context object (set by the list case in `router.php`). For error pages, these context keys are unset, so base.php receives nulls. ## Atom Feed Rendering @@ -79,9 +79,9 @@ setCachedMarkdown(string $filePath, string $html, string $langPrefix = ''): void Before content routing, the router serves static files from content directory with an explicit MIME type allowlist: -`css`, `jpg`, `jpeg`, `png`, `gif`, `webp`, `svg`, `pdf`, `woff`, `woff2`, `ttf`, `otf` +`css`, `jpg`, `jpeg`, `png`, `gif`, `webp`, `svg`, `pdf`, `woff`, `woff2`, `ttf`, `otf`, `js` -Files not in this list are not served as static assets. Notably, `.js` files are excluded — JavaScript must be placed in `custom/assets/` to be served (at the document root URL), or linked from an external source. +Files not in this list are not served as static assets. ### Custom Assets (router.php) @@ -100,9 +100,9 @@ Files in `custom/assets/` are served at the document root URL. Example: `custom/ MIME types resolved from extension map, falling back to `mime_content_type()`. -## CSS Cache Busting +## CSS and JS Cache Busting -Page-specific CSS gets an MD5 hash appended: `?v={hash}`. Computed by `findPageCss()`. The default theme's CSS is linked directly without hash (uses browser caching). +Page-specific CSS and JS get an MD5 hash appended: `?v={hash}`. Computed by `findPageCss()` and `findPageJs()` respectively. The default theme's CSS is linked directly without hash (uses browser caching). ## Parsedown