Compare commits

..

2 commits

Author SHA1 Message Date
Ruben
f1447049e4 Add page-specific JavaScript support
Update documentation for new JS variables and rendering pipeline

Add script.js file support to content directories

Implement cache-busting for JavaScript files

Update static file serving to include JavaScript files

Document page-specific script loading in base.php
2026-02-06 18:47:23 +01:00
Ruben
f8a352afce Add JavaScript support to page templates
Add support for page-specific JavaScript files with cache busting
via MD5 hash. The script is loaded at the end of the body with
defer attribute. The JavaScript file must be named script.js and
located in the same directory as the page content.
2026-02-06 18:47:17 +01:00
10 changed files with 107 additions and 8 deletions

View file

@ -54,5 +54,8 @@
<?php endif; ?>
</p>
</footer>
<?php if (!empty($pageJsUrl)): ?>
<script defer src="<?= htmlspecialchars($pageJsUrl) ?>?v=<?= htmlspecialchars($pageJsHash ?? '') ?>"></script>
<?php endif; ?>
</body>
</html>

View file

@ -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'])) {

View file

@ -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);

View file

@ -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

View file

@ -241,6 +241,33 @@ FolderWeb automatically loads and includes page-specific styles with cache-busti
<link rel="stylesheet" href="/portfolio/styles.css?v=abc123def">
```
## 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
<script defer src="/portfolio/script.js?v=abc123def"></script>
```
The script tag is placed before `</body>`, 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`:

View file

@ -128,6 +128,10 @@ Let's modify `base.php` to add your site name and custom navigation:
<small>Generated in <?= number_format($pageLoadTime, 4) ?>s</small>
</p>
</footer>
<?php if (!empty($pageJsUrl)): ?>
<script defer src="<?= htmlspecialchars($pageJsUrl) ?>?v=<?= $pageJsHash ?? '' ?>"></script>
<?php endif; ?>
</body>
</html>
```
@ -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
```

View file

@ -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
<?php if (!empty($pageJsUrl)): ?>
<script defer src="<?= htmlspecialchars($pageJsUrl) ?>?v=<?= htmlspecialchars($pageJsHash ?? '') ?>"></script>
<?php endif; ?>
```
### `$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` | — | — | ✓ |

View file

@ -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 `</body>` for non-blocking progressive enhancement.
## Meta Description Extraction
**`extractMetaDescription(string $dirPath, ?array $metadata): ?string`**

View file

@ -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 |

View file

@ -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