Skip to content

Commit 8a5c51d

Browse files
committed
Add CDN support for asset URLs (fixes #3)
Implements CDN (Content Delivery Network) support for managing asset URLs across different environments. This feature allows automatic URL prefixing based on configuration and optional cache-busting with timestamps. Features: - Macro syntax: {cdn 'assets/style.css'} - Filter syntax: {='assets/style.css'|cdn} - Standalone filter: CdnFilter::filter() - Environment-aware URL generation - Optional cache-busting with ?time=timestamp - Full DI integration with CdnExtension Implementation: - Created CdnNode for macro compilation - Created CdnExtension (Latte) for tag/filter registration - Created CdnExtension (DI) for Nette integration - Created CdnFilter for standalone usage - Added comprehensive tests for all components - Added complete documentation with usage examples Tests: - Extension tests (macro and filter syntax) - Filter tests (standalone usage) - DI integration tests - All 12 tests passing Closes #3
1 parent f558925 commit 8a5c51d

File tree

8 files changed

+571
-0
lines changed

8 files changed

+571
-0
lines changed

.docs/README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Extra contribution to [`nette/latte`](https://github.com/nette/latte).
66

77
- [Setup](#setup)
88
- [VersionExtension - revision macros for assets](#versions-extension)
9+
- [CdnExtension - CDN support for assets](#cdn-extension)
910
- [FiltersExtension - install filters easily](#filters-extension)
1011
- [RuntimeFilters - collection of prepared filters](#runtimefilters)
1112
- [Formatters - collection of prepared formatters](#formatters)
@@ -52,6 +53,97 @@ version:
5253
<link rel="stylesheet" href="{$basePath}/assets/theme.css?v={v}">
5354
```
5455

56+
## CDN Extension
57+
58+
This extension adds support for CDN (Content Delivery Network) URLs in your templates. It provides both a macro `{cdn}` and a filter `|cdn` for easy asset URL management across different environments.
59+
60+
### Install
61+
62+
```neon
63+
extensions:
64+
cdn: Contributte\Latte\DI\CdnExtension
65+
```
66+
67+
### Configuration
68+
69+
```neon
70+
cdn:
71+
url: https://cdn.example.com
72+
appendTime: false
73+
```
74+
75+
**Options:**
76+
77+
- `url` (string, default: `''`) - Base CDN URL. Leave empty for localhost/development mode.
78+
- `appendTime` (bool, default: `false`) - Automatically append `?time=timestamp` for cache busting.
79+
80+
### Usage
81+
82+
#### Using the macro syntax
83+
84+
```latte
85+
<link rel="stylesheet" href="{cdn 'assets/dist/style.css'}">
86+
<script src="{cdn 'assets/dist/app.js'}"></script>
87+
<img src="{cdn 'images/logo.png'}" alt="Logo">
88+
```
89+
90+
#### Using the filter syntax
91+
92+
```latte
93+
<link rel="stylesheet" href="{='assets/dist/style.css'|cdn}">
94+
<script src="{='assets/dist/app.js'|cdn}"></script>
95+
<img src="{='images/logo.png'|cdn}" alt="Logo">
96+
```
97+
98+
#### Using the standalone filter
99+
100+
```php
101+
use Contributte\Latte\Filters\CdnFilter;
102+
103+
$url = CdnFilter::filter('assets/style.css', [
104+
'url' => 'https://cdn.example.com',
105+
'appendTime' => true,
106+
]);
107+
// Result: https://cdn.example.com/assets/style.css?time=1234567890
108+
```
109+
110+
### Environment-specific behavior
111+
112+
**Development (localhost):**
113+
114+
```neon
115+
cdn:
116+
url: ""
117+
appendTime: true
118+
```
119+
120+
```latte
121+
{cdn 'assets/style.css'}
122+
```
123+
124+
Output: `/assets/style.css?time=1234567890`
125+
126+
**Production:**
127+
128+
```neon
129+
cdn:
130+
url: https://cdn.example.com
131+
appendTime: true
132+
```
133+
134+
```latte
135+
{cdn 'assets/style.css'}
136+
```
137+
138+
Output: `https://cdn.example.com/assets/style.css?time=1234567890`
139+
140+
### Benefits
141+
142+
- **Environment flexibility** - Switch between local and CDN URLs with configuration
143+
- **Cache busting** - Optional timestamp appending for automatic cache invalidation
144+
- **Multiple syntax options** - Use macro `{cdn}` or filter `|cdn` based on preference
145+
- **Clean templates** - No need to modify templates when switching between environments
146+
55147
## Filters Extension
56148

57149
Install filters by single extension and simple `FiltersProvider` implementation.

src/DI/CdnExtension.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Contributte\Latte\DI;
4+
5+
use Contributte\Latte\Exception\LogicalException;
6+
use Contributte\Latte\Extensions\CdnExtension as LatteCdnExtension;
7+
use Nette\Bridges\ApplicationLatte\LatteFactory;
8+
use Nette\DI\CompilerExtension;
9+
use Nette\DI\Definitions\FactoryDefinition;
10+
use Nette\DI\Definitions\Statement;
11+
use Nette\Schema\Expect;
12+
use Nette\Schema\Schema;
13+
use stdClass;
14+
15+
/**
16+
* @property-read stdClass $config
17+
*/
18+
class CdnExtension extends CompilerExtension
19+
{
20+
21+
public function getConfigSchema(): Schema
22+
{
23+
return Expect::structure([
24+
'url' => Expect::string()->default(''),
25+
'appendTime' => Expect::bool(false),
26+
]);
27+
}
28+
29+
public function beforeCompile(): void
30+
{
31+
$builder = $this->getContainerBuilder();
32+
$config = $this->config;
33+
34+
if ($builder->getByType(LatteFactory::class) === null) {
35+
throw new LogicalException('You have to register LatteFactory first.');
36+
}
37+
38+
$factoryDefinition = $builder->getDefinitionByType(LatteFactory::class);
39+
assert($factoryDefinition instanceof FactoryDefinition);
40+
41+
$factoryDefinition
42+
->getResultDefinition()
43+
->addSetup('addExtension', [new Statement(LatteCdnExtension::class, [(array) $config])]);
44+
}
45+
46+
}

src/Extensions/CdnExtension.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Contributte\Latte\Extensions;
4+
5+
use Contributte\Latte\Extensions\Node\CdnNode;
6+
use Latte\Extension;
7+
8+
class CdnExtension extends Extension
9+
{
10+
11+
/**
12+
* @param array{url?: string, appendTime?: bool} $config
13+
*/
14+
public function __construct(
15+
private array $config = [],
16+
)
17+
{
18+
}
19+
20+
public function getTags(): array
21+
{
22+
return [
23+
'cdn' => [CdnNode::class, 'create'],
24+
];
25+
}
26+
27+
public function getProviders(): array
28+
{
29+
return [
30+
'cdnBuilder' => function (string $path): string {
31+
$baseUrl = $this->config['url'] ?? '';
32+
$url = rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
33+
34+
if ($this->config['appendTime'] ?? false) {
35+
$separator = str_contains($url, '?') ? '&' : '?';
36+
$url .= $separator . 'time=' . time();
37+
}
38+
39+
return $url;
40+
},
41+
];
42+
}
43+
44+
public function getFilters(): array
45+
{
46+
return [
47+
'cdn' => function (string $path): string {
48+
$baseUrl = $this->config['url'] ?? '';
49+
$url = rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
50+
51+
if ($this->config['appendTime'] ?? false) {
52+
$separator = str_contains($url, '?') ? '&' : '?';
53+
$url .= $separator . 'time=' . time();
54+
}
55+
56+
return $url;
57+
},
58+
];
59+
}
60+
61+
}

src/Extensions/Node/CdnNode.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Contributte\Latte\Extensions\Node;
4+
5+
use Generator;
6+
use Latte\Compiler\Nodes\Php\ExpressionNode;
7+
use Latte\Compiler\Nodes\StatementNode;
8+
use Latte\Compiler\PrintContext;
9+
use Latte\Compiler\Tag;
10+
11+
class CdnNode extends StatementNode
12+
{
13+
14+
public ExpressionNode $path;
15+
16+
public static function create(Tag $tag): self
17+
{
18+
$node = new self();
19+
$node->path = $tag->parser->parseExpression();
20+
return $node;
21+
}
22+
23+
public function print(PrintContext $context): string
24+
{
25+
return $context->format(
26+
'echo LR\Filters::escapeHtmlAttr(call_user_func($this->global->cdnBuilder, %0.node)) %1.line;',
27+
$this->path,
28+
$this->position,
29+
);
30+
}
31+
32+
public function &getIterator(): Generator
33+
{
34+
yield $this->path;
35+
}
36+
37+
}

src/Filters/CdnFilter.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Contributte\Latte\Filters;
4+
5+
class CdnFilter
6+
{
7+
8+
/**
9+
* @param array{url?: string, appendTime?: bool} $parameters
10+
*/
11+
public static function filter(string $path, array $parameters = []): string
12+
{
13+
$baseUrl = $parameters['url'] ?? '';
14+
$url = rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
15+
16+
if ($parameters['appendTime'] ?? false) {
17+
$separator = str_contains($url, '?') ? '&' : '?';
18+
$url .= $separator . 'time=' . time();
19+
}
20+
21+
return $url;
22+
}
23+
24+
}

tests/Cases/DI/CdnExtension.phpt

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php declare(strict_types = 1);
2+
3+
use Contributte\Latte\DI\CdnExtension;
4+
use Contributte\Tester\Environment;
5+
use Contributte\Tester\Toolkit;
6+
use Nette\Bridges\ApplicationDI\LatteExtension;
7+
use Nette\Bridges\ApplicationLatte\ILatteFactory;
8+
use Nette\DI\Compiler;
9+
use Nette\DI\Container;
10+
use Nette\DI\ContainerLoader;
11+
use Tester\Assert;
12+
use Tester\FileMock;
13+
14+
require_once __DIR__ . '/../../bootstrap.php';
15+
16+
// Test DI integration with CDN URL
17+
Toolkit::test(function (): void {
18+
$loader = new ContainerLoader(Environment::getTestDir(), true);
19+
$class = $loader->load(function (Compiler $compiler): void {
20+
$compiler->addExtension('latte', new LatteExtension(Environment::getTestDir()));
21+
$compiler->addExtension('cdn', new CdnExtension());
22+
$compiler->loadConfig(FileMock::create('
23+
cdn:
24+
url: https://cdn.example.com
25+
', 'neon'));
26+
}, 1);
27+
28+
/** @var Container $container */
29+
$container = new $class();
30+
31+
/** @var ILatteFactory $latteFactory */
32+
$latteFactory = $container->getByType(ILatteFactory::class);
33+
34+
Assert::equal('https://cdn.example.com/assets/style.css', $latteFactory->create()->renderToString(FileMock::create('{cdn "assets/style.css"}', 'latte')));
35+
Assert::equal('https://cdn.example.com/assets/style.css', $latteFactory->create()->renderToString(FileMock::create('{="assets/style.css"|cdn}', 'latte')));
36+
});
37+
38+
// Test DI integration with CDN URL and appendTime
39+
Toolkit::test(function (): void {
40+
$loader = new ContainerLoader(Environment::getTestDir(), true);
41+
$class = $loader->load(function (Compiler $compiler): void {
42+
$compiler->addExtension('latte', new LatteExtension(Environment::getTestDir()));
43+
$compiler->addExtension('cdn', new CdnExtension());
44+
$compiler->loadConfig(FileMock::create('
45+
cdn:
46+
url: https://cdn.example.com
47+
appendTime: true
48+
', 'neon'));
49+
}, 2);
50+
51+
/** @var Container $container */
52+
$container = new $class();
53+
54+
/** @var ILatteFactory $latteFactory */
55+
$latteFactory = $container->getByType(ILatteFactory::class);
56+
57+
$result = $latteFactory->create()->renderToString(FileMock::create('{cdn "assets/style.css"}', 'latte'));
58+
Assert::true(str_starts_with($result, 'https://cdn.example.com/assets/style.css?time='));
59+
60+
$result = $latteFactory->create()->renderToString(FileMock::create('{="assets/style.css"|cdn}', 'latte'));
61+
Assert::true(str_starts_with($result, 'https://cdn.example.com/assets/style.css?time='));
62+
});
63+
64+
// Test DI integration without CDN URL (localhost mode)
65+
Toolkit::test(function (): void {
66+
$loader = new ContainerLoader(Environment::getTestDir(), true);
67+
$class = $loader->load(function (Compiler $compiler): void {
68+
$compiler->addExtension('latte', new LatteExtension(Environment::getTestDir()));
69+
$compiler->addExtension('cdn', new CdnExtension());
70+
$compiler->loadConfig(FileMock::create('
71+
cdn:
72+
url: ""
73+
appendTime: true
74+
', 'neon'));
75+
}, 3);
76+
77+
/** @var Container $container */
78+
$container = new $class();
79+
80+
/** @var ILatteFactory $latteFactory */
81+
$latteFactory = $container->getByType(ILatteFactory::class);
82+
83+
$result = $latteFactory->create()->renderToString(FileMock::create('{cdn "assets/style.css"}', 'latte'));
84+
Assert::true(str_starts_with($result, '/assets/style.css?time='));
85+
86+
$result = $latteFactory->create()->renderToString(FileMock::create('{="assets/style.css"|cdn}', 'latte'));
87+
Assert::true(str_starts_with($result, '/assets/style.css?time='));
88+
});
89+
90+
// Test DI integration with minimal config
91+
Toolkit::test(function (): void {
92+
$loader = new ContainerLoader(Environment::getTestDir(), true);
93+
$class = $loader->load(function (Compiler $compiler): void {
94+
$compiler->addExtension('latte', new LatteExtension(Environment::getTestDir()));
95+
$compiler->addExtension('cdn', new CdnExtension());
96+
}, 4);
97+
98+
/** @var Container $container */
99+
$container = new $class();
100+
101+
/** @var ILatteFactory $latteFactory */
102+
$latteFactory = $container->getByType(ILatteFactory::class);
103+
104+
Assert::equal('/assets/style.css', $latteFactory->create()->renderToString(FileMock::create('{cdn "assets/style.css"}', 'latte')));
105+
Assert::equal('/assets/style.css', $latteFactory->create()->renderToString(FileMock::create('{="assets/style.css"|cdn}', 'latte')));
106+
});

0 commit comments

Comments
 (0)