Skip to content

Commit 3f6caac

Browse files
committed
feat(asset): add AssetExtension for deduplicated CSS/JS inclusion
- require_css / require_js queue assets by file path (last-write-wins) - flush_css / flush_js output all queued tags and clear the queue - assign mode returns structured arrays with full metadata - URL scheme allowlist (http, https, protocol-relative, relative paths) - Entity-encoded URLs decoded before validation and stored decoded - Colons in query strings correctly allowed for proxy/cache-buster URLs - 28 tests covering dedup, scheme safety, attribute override, assign - README, TUTORIAL, and CHANGELOG updated
1 parent c70f1a5 commit 3f6caac

5 files changed

Lines changed: 629 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [1.1.0] - 2026-03-31
6+
7+
### New Features
8+
9+
* **AssetExtension** — deduplicated CSS and JS inclusion for templates
10+
- `require_css` / `require_js` queue assets by file path (last-write-wins for conflicting attributes)
11+
- `flush_css` / `flush_js` output all queued `<link>`/`<script>` tags and clear the queue
12+
- `assign` mode returns structured arrays with full metadata (file, media, defer, async)
13+
- URL scheme allowlist: `http://`, `https://`, protocol-relative, and relative paths only
14+
- Entity-encoded URLs are decoded before validation and stored decoded
15+
- Colons in query strings (e.g., `asset.php?src=https://...`) are correctly allowed
16+
517
## [1.0.1] - 2026-03-31
618

719
### Security

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use Xoops\SmartyExtensions\Extension\DataExtension;
2626
use Xoops\SmartyExtensions\Extension\SecurityExtension;
2727
use Xoops\SmartyExtensions\Extension\FormExtension;
2828
use Xoops\SmartyExtensions\Extension\XoopsCoreExtension;
29+
use Xoops\SmartyExtensions\Extension\AssetExtension;
2930

3031
$registry = new ExtensionRegistry();
3132
$registry->add(new TextExtension());
@@ -35,6 +36,7 @@ $registry->add(new DataExtension());
3536
$registry->add(new SecurityExtension($xoopsSecurity, $grouppermHandler));
3637
$registry->add(new FormExtension($xoopsSecurity));
3738
$registry->add(new XoopsCoreExtension());
39+
$registry->add(new AssetExtension());
3840

3941
$registry->registerAll($smarty);
4042
```
@@ -180,6 +182,19 @@ XOOPS-specific functions (config, users, modules, blocks).
180182
| `ray_dump` | function | Dump variable structure to Ray |
181183
| `ray_table` | function | Send array to Ray table display |
182184

185+
### AssetExtension
186+
187+
Deduplicated CSS and JS inclusion (pure PHP, no XOOPS dependencies).
188+
189+
| Plugin | Type | Description |
190+
|--------|------|-------------|
191+
| `require_css` | function | Queue a stylesheet (deduplicated by file path, last-write-wins) |
192+
| `require_js` | function | Queue a script (deduplicated, supports `defer`/`async`) |
193+
| `flush_css` | function | Output all `<link>` tags and reset the queue |
194+
| `flush_js` | function | Output all `<script>` tags and reset the queue |
195+
196+
Asset URLs are validated against a safe-scheme allowlist (`http://`, `https://`, `//`, relative paths). Unsafe schemes like `javascript:` and `data:` are silently rejected.
197+
183198
## Writing Custom Extensions
184199

185200
Extend `AbstractExtension` and override the getter methods:

docs/TUTORIAL.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ For the full plugin reference table, see [README.md](../README.md).
1616
- [Forms](#forms)
1717
- [Security and Permissions](#security-and-permissions)
1818
- [XOOPS Core Helpers](#xoops-core-helpers)
19+
- [Asset Management](#asset-management)
1920
- [Ray Debugging](#ray-debugging)
2021
- [Writing Your Own Extension](#writing-your-own-extension)
2122
- [Best Practices](#best-practices)
@@ -39,6 +40,7 @@ use Xoops\SmartyExtensions\Extension\SecurityExtension;
3940
use Xoops\SmartyExtensions\Extension\FormExtension;
4041
use Xoops\SmartyExtensions\Extension\XoopsCoreExtension;
4142
use Xoops\SmartyExtensions\Extension\RayDebugExtension;
43+
use Xoops\SmartyExtensions\Extension\AssetExtension;
4244

4345
$registry = new ExtensionRegistry();
4446
$registry->add(new TextExtension());
@@ -49,6 +51,7 @@ $registry->add(new SecurityExtension($xoopsSecurity, $grouppermHandler));
4951
$registry->add(new FormExtension($xoopsSecurity));
5052
$registry->add(new XoopsCoreExtension());
5153
$registry->add(new RayDebugExtension());
54+
$registry->add(new AssetExtension());
5255

5356
$registry->registerAll($smarty);
5457
```
@@ -92,6 +95,7 @@ Some extensions are pure PHP and work in any Smarty environment. Others require
9295
| FormExtension | Optional | `XoopsSecurity` for CSRF injection; works without it (no token) |
9396
| SecurityExtension | Yes | `XoopsSecurity`, `XoopsGroupPermHandler`, `$xoopsUser` global |
9497
| XoopsCoreExtension | Yes | `$xoopsConfig`, `$xoopsUser`, `xoops_getHandler()`, `XOOPS_URL` |
98+
| AssetExtension | No | Pure PHP |
9599
| RayDebugExtension | Yes | Debugbar module with RayLogger enabled, `ray()` function |
96100

97101
### Plugin types
@@ -846,6 +850,65 @@ The `options` array must contain a `block` key with a block object that implemen
846850

847851
---
848852

853+
## Asset Management
854+
855+
AssetExtension prevents duplicate `<link>` and `<script>` tags when multiple templates or blocks request the same stylesheet or script within a single page render. Pure PHP, no XOOPS dependencies.
856+
857+
### Queueing assets
858+
859+
Register CSS and JS files from anywhere in your templates — blocks, module templates, theme includes. Duplicates are deduplicated by file path. If the same file is registered again with different attributes (e.g., different `media` or `defer`), the later registration wins.
860+
861+
```smarty
862+
<{* In a block template *}>
863+
<{require_css file="modules/news/assets/news.css"}>
864+
<{require_js file="modules/news/assets/news.js" defer=true}>
865+
866+
<{* In another block — same file, no duplicate emitted *}>
867+
<{require_css file="modules/news/assets/news.css"}>
868+
869+
<{* External CDN assets *}>
870+
<{require_js file="https://cdn.example.com/lib.js" async=true}>
871+
872+
<{* Print stylesheet *}>
873+
<{require_css file="modules/news/assets/print.css" media="print"}>
874+
```
875+
876+
### Flushing assets in the theme
877+
878+
Place these in your theme footer to output all queued tags at once:
879+
880+
```smarty
881+
<{* In theme header — output all CSS *}>
882+
<{flush_css}>
883+
884+
<{* In theme footer — output all JS *}>
885+
<{flush_js}>
886+
```
887+
888+
After flushing, the queue is cleared. A second `flush_css` or `flush_js` call outputs nothing.
889+
890+
### Custom rendering with assign
891+
892+
Use `assign` to get the full metadata for custom rendering. The assigned value is a list of structured arrays, not just file paths:
893+
894+
```smarty
895+
<{flush_css assign="styles"}>
896+
<{foreach $styles as $entry}>
897+
<link rel="stylesheet" href="<{$entry.file|escape}>" media="<{$entry.media|escape}>">
898+
<{/foreach}>
899+
900+
<{flush_js assign="scripts"}>
901+
<{foreach $scripts as $entry}>
902+
<script src="<{$entry.file|escape}>"<{if $entry.defer}> defer<{/if}><{if $entry.async}> async<{/if}>></script>
903+
<{/foreach}>
904+
```
905+
906+
### URL safety
907+
908+
Asset URLs are validated against a safe-scheme allowlist. Only `http://`, `https://`, protocol-relative (`//cdn...`), and relative paths are accepted. Unsafe schemes like `javascript:` and `data:` are silently rejected, including entity-encoded variants. URLs with colons in query strings (e.g., `asset.php?src=https://cdn.example.com/lib.js`) are correctly allowed.
909+
910+
---
911+
849912
## Ray Debugging
850913

851914
RayDebugExtension sends template data to the [Ray](https://myray.app) desktop debugger. All functions silently no-op when Ray is not installed or the Debugbar RayLogger is disabled, so templates can safely contain Ray tags in production.
@@ -1058,3 +1121,5 @@ The `formatStatus()` example above intentionally returns `<span>` markup. This i
10581121
| `has_user_permission` always returns false | `XoopsGroupPermHandler` was not injected | Pass the handler: `new SecurityExtension($security, $grouppermHandler)` |
10591122
| Ray functions produce no output | Expected — they send data to the Ray desktop app, not the browser | Check that the Debugbar module is active, RayLogger is enabled, and the `ray()` helper function is installed. |
10601123
| Modifier output shows raw HTML tags | `\|escape` was applied after an HTML-producing modifier | Remove `\|escape` from `nl2p`, `highlight_text`, `linkify`, and similar modifiers that return markup. |
1124+
| `require_css` / `require_js` silently ignores a file | The URL contains an unsafe scheme (`data:`, `javascript:`) | Only `http://`, `https://`, protocol-relative (`//`), and relative paths are accepted. |
1125+
| `flush_css` / `flush_js` outputs nothing | No assets were queued, or the queue was already flushed | Each flush clears the queue. Call `require_css`/`require_js` before flushing. |

src/Extension/AssetExtension.php

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Xoops\SmartyExtensions\Extension;
6+
7+
use Xoops\SmartyExtensions\AbstractExtension;
8+
9+
/**
10+
* Asset management Smarty functions — deduplicated CSS and JS inclusion.
11+
*
12+
* Prevents duplicate <link> and <script> tags when multiple templates or
13+
* blocks request the same stylesheet or script within a single page render.
14+
*
15+
* Pure PHP — no XOOPS dependencies.
16+
*
17+
* Usage:
18+
* <{require_css file="modules/news/assets/news.css"}>
19+
* <{require_js file="modules/news/assets/news.js"}>
20+
* <{require_js file="https://cdn.example.com/lib.js" defer=true}>
21+
*
22+
* At the end of the page (typically in theme footer):
23+
* <{flush_css}>
24+
* <{flush_js}>
25+
*
26+
* Or collect them as structured arrays for custom rendering:
27+
* <{flush_css assign="styles"}>
28+
* <{flush_js assign="scripts"}>
29+
*
30+
* @copyright (c) 2000-2026 XOOPS Project (https://xoops.org)
31+
* @license GNU GPL 2 (https://www.gnu.org/licenses/gpl-2.0.html)
32+
*/
33+
final class AssetExtension extends AbstractExtension
34+
{
35+
/** @var array<string, array{file: string, media: string}> */
36+
private array $css = [];
37+
38+
/** @var array<string, array{file: string, defer: bool, async: bool}> */
39+
private array $js = [];
40+
41+
public function getFunctions(): array
42+
{
43+
return [
44+
'require_css' => $this->requireCss(...),
45+
'require_js' => $this->requireJs(...),
46+
'flush_css' => $this->flushCss(...),
47+
'flush_js' => $this->flushJs(...),
48+
];
49+
}
50+
51+
/**
52+
* Register a CSS stylesheet for inclusion.
53+
*
54+
* If the same file is registered again with different attributes,
55+
* the later registration wins (last-write-wins).
56+
*
57+
* Parameters:
58+
* file — URL or path to the stylesheet (required)
59+
* media — media attribute (default: "all")
60+
*/
61+
public function requireCss(array $params, object $template): string
62+
{
63+
$file = $this->sanitizeAssetUrl($params['file'] ?? '');
64+
if ($file === '') {
65+
return '';
66+
}
67+
68+
$this->css[$file] = [
69+
'file' => $file,
70+
'media' => $params['media'] ?? 'all',
71+
];
72+
73+
return '';
74+
}
75+
76+
/**
77+
* Register a JavaScript file for inclusion.
78+
*
79+
* If the same file is registered again with different attributes,
80+
* the later registration wins (last-write-wins).
81+
*
82+
* Parameters:
83+
* file — URL or path to the script (required)
84+
* defer — add defer attribute (default: false)
85+
* async — add async attribute (default: false)
86+
*/
87+
public function requireJs(array $params, object $template): string
88+
{
89+
$file = $this->sanitizeAssetUrl($params['file'] ?? '');
90+
if ($file === '') {
91+
return '';
92+
}
93+
94+
$this->js[$file] = [
95+
'file' => $file,
96+
'defer' => !empty($params['defer']),
97+
'async' => !empty($params['async']),
98+
];
99+
100+
return '';
101+
}
102+
103+
/**
104+
* Output all registered CSS <link> tags and reset the queue.
105+
*
106+
* With assign: stores a list of entry arrays (file, media) for custom rendering.
107+
*/
108+
public function flushCss(array $params, object $template): string
109+
{
110+
if (!empty($params['assign'])) {
111+
$template->assign($params['assign'], \array_values($this->css));
112+
$this->css = [];
113+
return '';
114+
}
115+
116+
$html = '';
117+
foreach ($this->css as $entry) {
118+
$safeFile = \htmlspecialchars($entry['file'], ENT_QUOTES, 'UTF-8');
119+
$safeMedia = \htmlspecialchars($entry['media'], ENT_QUOTES, 'UTF-8');
120+
$html .= '<link rel="stylesheet" href="' . $safeFile . '" media="' . $safeMedia . '">' . "\n";
121+
}
122+
123+
$this->css = [];
124+
125+
return $html;
126+
}
127+
128+
/**
129+
* Output all registered JS <script> tags and reset the queue.
130+
*
131+
* With assign: stores a list of entry arrays (file, defer, async) for custom rendering.
132+
*/
133+
public function flushJs(array $params, object $template): string
134+
{
135+
if (!empty($params['assign'])) {
136+
$template->assign($params['assign'], \array_values($this->js));
137+
$this->js = [];
138+
return '';
139+
}
140+
141+
$html = '';
142+
foreach ($this->js as $entry) {
143+
$safeFile = \htmlspecialchars($entry['file'], ENT_QUOTES, 'UTF-8');
144+
$attrs = '';
145+
if ($entry['defer']) {
146+
$attrs .= ' defer';
147+
}
148+
if ($entry['async']) {
149+
$attrs .= ' async';
150+
}
151+
$html .= '<script src="' . $safeFile . '"' . $attrs . '></script>' . "\n";
152+
}
153+
154+
$this->js = [];
155+
156+
return $html;
157+
}
158+
159+
/**
160+
* Decode HTML entities and validate the URL scheme.
161+
*
162+
* Accepts: http://, https://, relative paths, protocol-relative (//cdn...).
163+
* Rejects: javascript:, data:, and any other unsafe scheme.
164+
*
165+
* Returns the decoded URL on success, or empty string on rejection.
166+
*/
167+
private function sanitizeAssetUrl(string $url): string
168+
{
169+
$decoded = \html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8');
170+
171+
// Absolute with safe scheme
172+
if (\preg_match('#^https?://#i', $decoded)) {
173+
return $decoded;
174+
}
175+
176+
// Protocol-relative
177+
if (\str_starts_with($decoded, '//')) {
178+
return $decoded;
179+
}
180+
181+
// Relative path: only reject if a scheme-like colon appears in the
182+
// path portion before any / or ? (colons in query strings are fine)
183+
$schemeEnd = \strcspn($decoded, '/?');
184+
if (!\str_contains(\substr($decoded, 0, $schemeEnd), ':')) {
185+
return $decoded;
186+
}
187+
188+
return '';
189+
}
190+
}

0 commit comments

Comments
 (0)