diff --git a/src/Assets/JsAssetEntry.php b/src/Assets/JsAssetEntry.php new file mode 100644 index 00000000..96c1aa93 --- /dev/null +++ b/src/Assets/JsAssetEntry.php @@ -0,0 +1,35 @@ + Map of file => uri (null if not found) */ public function resolveMany(array $files, string $app = 'horde'): array; + + /** + * Resolve theme-shipped JavaScript for a request. + * + * Implementations without theme knowledge return an empty result. + */ + public function discoverTheme(JsDiscoveryRequest $request): JsDiscoveryResult; } diff --git a/src/Assets/JsDiscoveryRequest.php b/src/Assets/JsDiscoveryRequest.php new file mode 100644 index 00000000..77d377b9 --- /dev/null +++ b/src/Assets/JsDiscoveryRequest.php @@ -0,0 +1,39 @@ + $files */ + public function __construct( + public readonly array $files = [], + public readonly string $app = 'horde', + public readonly string $theme = 'default', + ) {} +} diff --git a/src/Assets/JsDiscoveryResult.php b/src/Assets/JsDiscoveryResult.php new file mode 100644 index 00000000..9aee808f --- /dev/null +++ b/src/Assets/JsDiscoveryResult.php @@ -0,0 +1,75 @@ + + * + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Core + */ +final class JsDiscoveryResult implements IteratorAggregate, Countable +{ + /** @param list $entries */ + public function __construct( + private readonly array $entries, + private readonly string $theme, + private readonly string $app, + ) {} + + /** @return ArrayIterator */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->entries); + } + + public function count(): int + { + return count($this->entries); + } + + public function getTheme(): string + { + return $this->theme; + } + + public function getApp(): string + { + return $this->app; + } + + public function isEmpty(): bool + { + return $this->entries === []; + } + + /** @return list */ + public function toArray(): array + { + return $this->entries; + } +} diff --git a/src/Assets/PathBasedJsDiscoverer.php b/src/Assets/PathBasedJsDiscoverer.php index c3af3dc0..39cf3fea 100644 --- a/src/Assets/PathBasedJsDiscoverer.php +++ b/src/Assets/PathBasedJsDiscoverer.php @@ -51,4 +51,11 @@ public function resolveMany(array $files, string $app = 'horde'): array return $result; } + + public function discoverTheme(JsDiscoveryRequest $request): JsDiscoveryResult + { + /* This discoverer has no theme knowledge; theme scripts are handled + * by ThemeJsDiscoverer. */ + return new JsDiscoveryResult([], $request->theme, $request->app); + } } diff --git a/src/Assets/PhpThemeInfoReader.php b/src/Assets/PhpThemeInfoReader.php new file mode 100644 index 00000000..7281be45 --- /dev/null +++ b/src/Assets/PhpThemeInfoReader.php @@ -0,0 +1,105 @@ +declaredScripts($app, $theme) as $script) { + $script = (string) $script; + if (!preg_match(self::SCRIPT_NAME, $script) || strpos($script, '..') !== false) { + continue; + } + + $fsPath = (string) $this->pathBuilder + ->withAppThemesDir($app) + ->withSlug($theme) + ->withPart($script); + + if ($this->filesystem->isReadable($fsPath)) { + $scripts[] = $script; + } + } + + return $scripts; + } + + /** + * Include the theme's info.php in an isolated scope and return its + * declared $theme_scripts. Any read/parse failure yields an empty array. + * + * @return array + */ + private function declaredScripts(string $app, string $theme): array + { + $info = (string) $this->pathBuilder + ->withAppThemesDir($app) + ->withSlug($theme) + ->withPart('info.php'); + + if (!$this->filesystem->isReadable($info)) { + return []; + } + + /* Declared before the include so the theme file only ever augments a + * known-shape local; nothing from the outer scope leaks in. */ + $theme_scripts = []; + + include $info; + + return (array) $theme_scripts; + } +} diff --git a/src/Assets/ThemeInfoReader.php b/src/Assets/ThemeInfoReader.php new file mode 100644 index 00000000..ed724bf9 --- /dev/null +++ b/src/Assets/ThemeInfoReader.php @@ -0,0 +1,52 @@ + Validated script file names, in declaration order. + */ + public function readScripts(string $app, string $theme): array; +} diff --git a/src/Assets/ThemeJsDiscoverer.php b/src/Assets/ThemeJsDiscoverer.php new file mode 100644 index 00000000..578ad8d1 --- /dev/null +++ b/src/Assets/ThemeJsDiscoverer.php @@ -0,0 +1,113 @@ +pathBuilder + ->withAppJsDir($app) + ->withPart($file); + + if (!$this->filesystem->fileExists($fsPath)) { + return null; + } + + return (string) $this->uriBuilder + ->withJsUri($app) + ->withPart($file); + } + + public function resolveMany(array $files, string $app = 'horde'): array + { + $result = []; + foreach ($files as $file) { + $result[$file] = $this->resolve($file, $app); + } + + return $result; + } + + public function discoverTheme(JsDiscoveryRequest $request): JsDiscoveryResult + { + $entries = []; + + /* Base 'horde' theme first, then the application theme on top. */ + $this->collectLevel($entries, 'horde', $request->theme, $request->files); + if ($request->app !== 'horde') { + $this->collectLevel($entries, $request->app, $request->theme, $request->files); + } + + return new JsDiscoveryResult($entries, $request->theme, $request->app); + } + + /** + * @param list $entries + * @param list $explicitFiles Caller-supplied files; when + * empty the theme's own + * declarations are used. + */ + private function collectLevel(array &$entries, string $app, string $theme, array $explicitFiles): void + { + $files = $explicitFiles !== [] + ? $explicitFiles + : $this->themeInfo->readScripts($app, $theme); + + foreach ($files as $file) { + $fsPath = (string) $this->pathBuilder + ->withAppThemesDir($app) + ->withSlug($theme) + ->withPart($file); + + if (!$this->filesystem->fileExists($fsPath)) { + continue; + } + + $uri = (string) $this->uriBuilder + ->withThemesUri($app) + ->withSlug($theme) + ->withPart($file); + + $entries[] = new JsAssetEntry($fsPath, $uri, $app); + } + } +} diff --git a/src/Factory/JsDiscovererFactory.php b/src/Factory/JsDiscovererFactory.php index 5bba8c39..d0ae9a3b 100644 --- a/src/Factory/JsDiscovererFactory.php +++ b/src/Factory/JsDiscovererFactory.php @@ -18,7 +18,8 @@ use Horde\Core\Assets\AssetFilesystem; use Horde\Core\Assets\JsDiscoverer; -use Horde\Core\Assets\PathBasedJsDiscoverer; +use Horde\Core\Assets\ThemeInfoReader; +use Horde\Core\Assets\ThemeJsDiscoverer; use Horde\Core\Path\PathBuilderInterface; use Horde\Core\Uri\UriBuilderInterface; use Horde\Injector\Injector; @@ -30,7 +31,8 @@ public function create(Injector $injector): JsDiscoverer $pathBuilder = $injector->getInstance(PathBuilderInterface::class); $uriBuilder = $injector->getInstance(UriBuilderInterface::class); $filesystem = $injector->getInstance(AssetFilesystem::class); + $themeInfo = $injector->getInstance(ThemeInfoReader::class); - return new PathBasedJsDiscoverer($pathBuilder, $uriBuilder, $filesystem); + return new ThemeJsDiscoverer($pathBuilder, $uriBuilder, $filesystem, $themeInfo); } } diff --git a/src/Factory/ThemeInfoReaderFactory.php b/src/Factory/ThemeInfoReaderFactory.php new file mode 100644 index 00000000..e2ba96a9 --- /dev/null +++ b/src/Factory/ThemeInfoReaderFactory.php @@ -0,0 +1,34 @@ +getInstance(PathBuilderInterface::class), + $injector->getInstance(AssetFilesystem::class), + ); + } +} diff --git a/src/PageOutput/DesktopChromeRenderer.php b/src/PageOutput/DesktopChromeRenderer.php index 6ec84eea..ec559d5d 100644 --- a/src/PageOutput/DesktopChromeRenderer.php +++ b/src/PageOutput/DesktopChromeRenderer.php @@ -17,6 +17,7 @@ namespace Horde\Core\PageOutput; use Horde\Core\Assets\JsDiscoverer; +use Horde\Core\Assets\JsDiscoveryRequest; use Horde\Core\Sidebar\SidebarRenderer; use Horde\Core\Topbar\TopbarBuilder; use Horde\Core\Topbar\TopbarRenderer; @@ -34,6 +35,7 @@ public function __construct( private readonly TopbarRenderer $topbarRenderer, private readonly SidebarRenderer $sidebarRenderer, private readonly JsDiscoverer $jsDiscoverer, + private readonly string $theme = 'default', ) {} public function renderPage(PageContent $content, ServerRequestInterface $request): string @@ -53,6 +55,14 @@ public function renderPage(PageContent $content, ServerRequestInterface $request } } + /* Scripts the active theme ships for itself (info.php $theme_scripts). */ + $themeScripts = $this->jsDiscoverer->discoverTheme( + new JsDiscoveryRequest(app: $content->app, theme: $this->theme), + ); + foreach ($themeScripts as $entry) { + $this->assetCollector->addScript($entry->uri); + } + $meta = new PageMeta(title: $content->title); $html = $this->pageComposer->renderHead($meta); diff --git a/src/PageOutput/DesktopChromeRendererFactory.php b/src/PageOutput/DesktopChromeRendererFactory.php index 03e272bb..7d2f50fe 100644 --- a/src/PageOutput/DesktopChromeRendererFactory.php +++ b/src/PageOutput/DesktopChromeRendererFactory.php @@ -17,6 +17,8 @@ namespace Horde\Core\PageOutput; use Horde\Core\Assets\JsDiscoverer; +use Horde\Core\Assets\ThemeResolver; +use Horde\Core\Session\SessionAccess; use Horde\Core\Sidebar\SidebarRenderer; use Horde\Core\Topbar\TopbarBuilder; use Horde\Core\Topbar\TopbarRenderer; @@ -26,6 +28,12 @@ class DesktopChromeRendererFactory { public function create(Injector $injector): DesktopChromeRenderer { + $session = $injector->getInstance(SessionAccess::class); + $themeResolver = $injector->get(ThemeResolver::class); + + $authUid = $session->getAuthId(); + $theme = $authUid !== null ? $themeResolver->resolve($authUid) : 'default'; + return new DesktopChromeRenderer( $injector->getInstance(AssetCollector::class), $injector->getInstance(PageComposer::class), @@ -34,6 +42,7 @@ public function create(Injector $injector): DesktopChromeRenderer $injector->getInstance(TopbarRenderer::class), $injector->getInstance(SidebarRenderer::class), $injector->getInstance(JsDiscoverer::class), + $theme, ); } } diff --git a/src/PageOutput/ResponsiveChromeRenderer.php b/src/PageOutput/ResponsiveChromeRenderer.php index 277e15a5..adb2ac46 100644 --- a/src/PageOutput/ResponsiveChromeRenderer.php +++ b/src/PageOutput/ResponsiveChromeRenderer.php @@ -20,6 +20,7 @@ use Horde\Core\Assets\CssDiscoverer; use Horde\Core\Assets\CssDiscoveryRequest; use Horde\Core\Assets\JsDiscoverer; +use Horde\Core\Assets\JsDiscoveryRequest; use Horde\Injector\Attribute\Factory; use Psr\Http\Message\ServerRequestInterface; @@ -36,6 +37,7 @@ public function __construct( private readonly CssDiscoverer $cssDiscoverer, private readonly JsDiscoverer $jsDiscoverer, Closure $topbarFactory, + private readonly string $theme = 'default', ) { $this->topbarFactory = $topbarFactory; } @@ -96,6 +98,14 @@ private function buildJsUrls(string $app, array $extraFiles): array $jsUrls[] = $uri; } + /* Scripts the active theme ships for itself (info.php $theme_scripts). */ + $themeScripts = $this->jsDiscoverer->discoverTheme( + new JsDiscoveryRequest(app: $app, theme: $this->theme), + ); + foreach ($themeScripts as $entry) { + $jsUrls[] = $entry->uri; + } + return $jsUrls; } diff --git a/src/PageOutput/ResponsiveChromeRendererFactory.php b/src/PageOutput/ResponsiveChromeRendererFactory.php index 2e3d8a8e..cdcec1d2 100644 --- a/src/PageOutput/ResponsiveChromeRendererFactory.php +++ b/src/PageOutput/ResponsiveChromeRendererFactory.php @@ -48,6 +48,7 @@ public function create(Injector $injector): ResponsiveChromeRenderer $injector->get(CssDiscoverer::class), $injector->get(JsDiscoverer::class), $topbarFactory, + $theme, ); } } diff --git a/test/Unit/Assets/JsDiscoveryRequestTest.php b/test/Unit/Assets/JsDiscoveryRequestTest.php new file mode 100644 index 00000000..abe3af59 --- /dev/null +++ b/test/Unit/Assets/JsDiscoveryRequestTest.php @@ -0,0 +1,38 @@ +files); + self::assertSame('horde', $request->app); + self::assertSame('default', $request->theme); + } + + #[Test] + public function customValues(): void + { + $request = new JsDiscoveryRequest( + files: ['theme.js'], + app: 'turba', + theme: 'silver', + ); + + self::assertSame(['theme.js'], $request->files); + self::assertSame('turba', $request->app); + self::assertSame('silver', $request->theme); + } +} diff --git a/test/Unit/Assets/JsDiscoveryResultTest.php b/test/Unit/Assets/JsDiscoveryResultTest.php new file mode 100644 index 00000000..e56b4839 --- /dev/null +++ b/test/Unit/Assets/JsDiscoveryResultTest.php @@ -0,0 +1,57 @@ +isEmpty()); + self::assertCount(0, $result); + self::assertSame([], $result->toArray()); + self::assertSame('default', $result->getTheme()); + self::assertSame('horde', $result->getApp()); + } + + #[Test] + public function iteratesEntriesInOrder(): void + { + $a = new JsAssetEntry('/fs/a.js', '/uri/a.js', 'horde'); + $b = new JsAssetEntry('/fs/b.js', '/uri/b.js', 'turba'); + $result = new JsDiscoveryResult([$a, $b], 'silver', 'turba'); + + self::assertFalse($result->isEmpty()); + self::assertCount(2, $result); + + $collected = []; + foreach ($result as $entry) { + $collected[] = $entry->uri; + } + + self::assertSame(['/uri/a.js', '/uri/b.js'], $collected); + self::assertSame([$a, $b], $result->toArray()); + } + + #[Test] + public function entryExposesFields(): void + { + $entry = new JsAssetEntry('/fs/theme.js', '/uri/theme.js', 'turba'); + + self::assertSame('/fs/theme.js', $entry->fsPath); + self::assertSame('/uri/theme.js', $entry->uri); + self::assertSame('turba', $entry->app); + } +} diff --git a/test/Unit/Assets/PathBasedJsDiscovererTest.php b/test/Unit/Assets/PathBasedJsDiscovererTest.php index 1537ceb7..b750c9d5 100644 --- a/test/Unit/Assets/PathBasedJsDiscovererTest.php +++ b/test/Unit/Assets/PathBasedJsDiscovererTest.php @@ -6,6 +6,7 @@ use Horde\Core\Assets\AssetFilesystem; use Horde\Core\Assets\JsDiscoverer; +use Horde\Core\Assets\JsDiscoveryRequest; use Horde\Core\Assets\PathBasedJsDiscoverer; use Horde\Core\Path\PathBuilderInterface; use Horde\Core\Uri\UriBuilderInterface; @@ -112,6 +113,18 @@ public function resolveManyEmpty(): void self::assertSame([], $result); } + #[Test] + public function discoverThemeIsAlwaysEmpty(): void + { + $discoverer = new PathBasedJsDiscoverer($this->pathBuilder, $this->uriBuilder, $this->filesystem); + $result = $discoverer->discoverTheme(new JsDiscoveryRequest(app: 'turba', theme: 'silver')); + + self::assertTrue($result->isEmpty()); + self::assertCount(0, $result); + self::assertSame('silver', $result->getTheme()); + self::assertSame('turba', $result->getApp()); + } + /** @param array $appJsDirs */ private function createPathMock(array $appJsDirs): PathBuilderInterface { diff --git a/test/Unit/Assets/PhpThemeInfoReaderTest.php b/test/Unit/Assets/PhpThemeInfoReaderTest.php new file mode 100644 index 00000000..94f54294 --- /dev/null +++ b/test/Unit/Assets/PhpThemeInfoReaderTest.php @@ -0,0 +1,215 @@ +root = sys_get_temp_dir() . '/horde-theme-info-' . uniqid('', true); + $this->pathBuilder = $this->createPathMock($this->root); + $this->filesystem = new LocalAssetFilesystem(); + } + + protected function tearDown(): void + { + $this->removeTree($this->root); + } + + #[Test] + public function implementsInterface(): void + { + $reader = new PhpThemeInfoReader($this->pathBuilder, $this->filesystem); + + self::assertInstanceOf(ThemeInfoReader::class, $reader); + } + + #[Test] + public function returnsDeclaredReadableScript(): void + { + $this->writeTheme('horde', 'silver', "pathBuilder, $this->filesystem); + + self::assertSame(['theme.js'], $reader->readScripts('horde', 'silver')); + } + + #[Test] + public function dropsDeclaredButMissingFile(): void + { + $this->writeTheme('horde', 'silver', "pathBuilder, $this->filesystem); + + self::assertSame(['theme.js'], $reader->readScripts('horde', 'silver')); + } + + #[Test] + public function rejectsUnsafeScriptNames(): void + { + $this->writeTheme( + 'horde', + 'silver', + "pathBuilder, $this->filesystem); + + self::assertSame([], $reader->readScripts('horde', 'silver')); + } + + #[Test] + public function rejectsUnsafeThemeName(): void + { + $reader = new PhpThemeInfoReader($this->pathBuilder, $this->filesystem); + + self::assertSame([], $reader->readScripts('horde', '../etc')); + self::assertSame([], $reader->readScripts('horde', 'foo/bar')); + self::assertSame([], $reader->readScripts('horde', '')); + } + + #[Test] + public function missingInfoFileYieldsEmpty(): void + { + // Theme dir exists but no info.php. + $dir = $this->root . '/horde/silver'; + mkdir($dir, 0777, true); + + $reader = new PhpThemeInfoReader($this->pathBuilder, $this->filesystem); + + self::assertSame([], $reader->readScripts('horde', 'silver')); + } + + #[Test] + public function infoWithoutScriptsYieldsEmpty(): void + { + $this->writeTheme('horde', 'silver', "pathBuilder, $this->filesystem); + + self::assertSame([], $reader->readScripts('horde', 'silver')); + } + + #[Test] + public function resolvesPerApp(): void + { + $this->writeTheme('turba', 'silver', "pathBuilder, $this->filesystem); + + self::assertSame(['contacts.js'], $reader->readScripts('turba', 'silver')); + self::assertSame([], $reader->readScripts('horde', 'silver')); + } + + /** + * @param list $scriptFiles Script files to create alongside info.php. + */ + private function writeTheme(string $app, string $theme, string $info, array $scriptFiles): void + { + $dir = $this->root . '/' . $app . '/' . $theme; + mkdir($dir, 0777, true); + file_put_contents($dir . '/info.php', $info); + foreach ($scriptFiles as $file) { + file_put_contents($dir . '/' . $file, '// js'); + } + } + + private function removeTree(string $path): void + { + if (!is_dir($path)) { + return; + } + $items = scandir($path); + foreach ($items === false ? [] : $items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $full = $path . '/' . $item; + is_dir($full) ? $this->removeTree($full) : unlink($full); + } + rmdir($path); + } + + private function createPathMock(string $root): PathBuilderInterface + { + return new class ($root) implements PathBuilderInterface { + private string $path = ''; + + public function __construct(private readonly string $root) {} + + public function withComponentRoot(): static + { + return $this; + } + public function withAppFileroot(string $app): static + { + return $this; + } + + public function withAppThemesDir(string $app): static + { + $clone = clone $this; + $clone->path = $this->root . '/' . $app; + return $clone; + } + + public function withAppJsDir(string $app): static + { + return $this; + } + public function withStaticDir(): static + { + return $this; + } + public function withConfigDir(?string $app = null): static + { + return $this; + } + public function withTmpDir(): static + { + return $this; + } + + public function withSlug(string $slug): static + { + $clone = clone $this; + $clone->path = $this->path . '/' . $slug; + return $clone; + } + + public function withPart(string $part): static + { + $clone = clone $this; + $clone->path = $this->path . '/' . $part; + return $clone; + } + + public function toSplFileInfo(): SplFileInfo + { + return new SplFileInfo($this->path); + } + public function __toString(): string + { + return $this->path; + } + }; + } +} diff --git a/test/Unit/Assets/ThemeJsDiscovererTest.php b/test/Unit/Assets/ThemeJsDiscovererTest.php new file mode 100644 index 00000000..ab26ac66 --- /dev/null +++ b/test/Unit/Assets/ThemeJsDiscovererTest.php @@ -0,0 +1,382 @@ +root = sys_get_temp_dir() . '/horde-theme-js-' . uniqid('', true); + mkdir($this->root, 0777, true); + $this->pathBuilder = $this->createPathMock($this->root); + $this->uriBuilder = $this->createUriMock(); + $this->filesystem = new LocalAssetFilesystem(); + } + + protected function tearDown(): void + { + $this->removeTree($this->root); + } + + #[Test] + public function implementsJsDiscoverer(): void + { + $reader = $this->createMock(ThemeInfoReader::class); + $reader->expects(self::never())->method('readScripts'); + + $discoverer = $this->discoverer($reader); + + self::assertInstanceOf(JsDiscoverer::class, $discoverer); + } + + #[Test] + public function flatResolveStillUsesAppJsDir(): void + { + $this->createFile('horde/js/topbar.js'); + + $reader = $this->createMock(ThemeInfoReader::class); + $reader->expects(self::never())->method('readScripts'); + + $discoverer = $this->discoverer($reader); + + self::assertSame('/uri/horde/js/topbar.js', $discoverer->resolve('topbar.js')); + self::assertNull($discoverer->resolve('missing.js')); + } + + #[Test] + public function discoverThemeUsesThemeDeclarations(): void + { + $this->createFile('horde/themes/silver/theme.js'); + + $reader = $this->createMock(ThemeInfoReader::class); + $reader->expects(self::atLeastOnce()) + ->method('readScripts') + ->with('horde', 'silver') + ->willReturn(['theme.js']); + + $result = $this->discoverer($reader) + ->discoverTheme(new JsDiscoveryRequest(app: 'horde', theme: 'silver')); + + self::assertCount(1, $result); + self::assertSame('/uri/horde/themes/silver/theme.js', $result->toArray()[0]->uri); + } + + #[Test] + public function discoverThemeCascadesHordeThenApp(): void + { + $this->createFile('horde/themes/silver/base.js'); + $this->createFile('turba/themes/silver/app.js'); + + $reader = $this->createMock(ThemeInfoReader::class); + $reader->expects(self::atLeastOnce()) + ->method('readScripts') + ->willReturnMap([ + ['horde', 'silver', ['base.js']], + ['turba', 'silver', ['app.js']], + ]); + + $result = $this->discoverer($reader) + ->discoverTheme(new JsDiscoveryRequest(app: 'turba', theme: 'silver')); + + $uris = array_map(static fn($e): string => $e->uri, $result->toArray()); + + self::assertSame([ + '/uri/horde/themes/silver/base.js', + '/uri/turba/themes/silver/app.js', + ], $uris); + } + + #[Test] + public function discoverThemeSkipsMissingDeclaredFile(): void + { + $reader = $this->createMock(ThemeInfoReader::class); + $reader->expects(self::atLeastOnce()) + ->method('readScripts') + ->with('horde', 'silver') + ->willReturn(['ghost.js']); + + $result = $this->discoverer($reader) + ->discoverTheme(new JsDiscoveryRequest(app: 'horde', theme: 'silver')); + + self::assertTrue($result->isEmpty()); + } + + #[Test] + public function discoverThemeEmptyWhenNoDeclarations(): void + { + $reader = $this->createMock(ThemeInfoReader::class); + $reader->expects(self::atLeastOnce()) + ->method('readScripts') + ->with('horde', 'default') + ->willReturn([]); + + $result = $this->discoverer($reader) + ->discoverTheme(new JsDiscoveryRequest(app: 'horde', theme: 'default')); + + self::assertTrue($result->isEmpty()); + } + + #[Test] + public function discoverThemeExplicitFilesBypassReader(): void + { + $this->createFile('horde/themes/silver/given.js'); + + $reader = $this->createMock(ThemeInfoReader::class); + $reader->expects(self::never())->method('readScripts'); + + $result = $this->discoverer($reader)->discoverTheme( + new JsDiscoveryRequest(files: ['given.js'], app: 'horde', theme: 'silver'), + ); + + self::assertCount(1, $result); + self::assertSame('/uri/horde/themes/silver/given.js', $result->toArray()[0]->uri); + } + + private function discoverer(ThemeInfoReader $reader): ThemeJsDiscoverer + { + return new ThemeJsDiscoverer($this->pathBuilder, $this->uriBuilder, $this->filesystem, $reader); + } + + private function createFile(string $relative): void + { + $full = $this->root . '/' . $relative; + $dir = dirname($full); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + file_put_contents($full, '// js'); + } + + private function removeTree(string $path): void + { + if (!is_dir($path)) { + return; + } + $items = scandir($path); + foreach ($items === false ? [] : $items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $full = $path . '/' . $item; + is_dir($full) ? $this->removeTree($full) : unlink($full); + } + rmdir($path); + } + + private function createPathMock(string $root): PathBuilderInterface + { + return new class ($root) implements PathBuilderInterface { + private string $path = ''; + + public function __construct(private readonly string $root) + { + $this->path = $root; + } + + public function withComponentRoot(): static + { + return $this; + } + public function withAppFileroot(string $app): static + { + return $this; + } + + public function withAppThemesDir(string $app): static + { + $clone = clone $this; + $clone->path = $this->root . '/' . $app . '/themes'; + return $clone; + } + + public function withAppJsDir(string $app): static + { + $clone = clone $this; + $clone->path = $this->root . '/' . $app . '/js'; + return $clone; + } + + public function withStaticDir(): static + { + return $this; + } + public function withConfigDir(?string $app = null): static + { + return $this; + } + public function withTmpDir(): static + { + return $this; + } + + public function withSlug(string $slug): static + { + $clone = clone $this; + $clone->path = $this->path . '/' . $slug; + return $clone; + } + + public function withPart(string $part): static + { + $clone = clone $this; + $clone->path = $this->path . '/' . $part; + return $clone; + } + + public function toSplFileInfo(): SplFileInfo + { + return new SplFileInfo($this->path); + } + public function __toString(): string + { + return $this->path; + } + }; + } + + private function createUriMock(): UriBuilderInterface + { + return new class implements UriBuilderInterface, Stringable { + private string $path = '/uri'; + + public function withAppWebroot(string $app): static + { + return $this; + } + + public function withThemesUri(string $app): static + { + $clone = clone $this; + $clone->path = '/uri/' . $app . '/themes'; + return $clone; + } + + public function withJsUri(string $app): static + { + $clone = clone $this; + $clone->path = '/uri/' . $app . '/js'; + return $clone; + } + + public function withStaticUri(): static + { + return $this; + } + public function withNamedRoute(string $app, string $name, array $params = []): static + { + return $this; + } + public function withQueryParams(array $params): static + { + return $this; + } + + public function withSlug(string $slug): static + { + $clone = clone $this; + $clone->path = $this->path . '/' . $slug; + return $clone; + } + + public function withPart(string $part): static + { + $clone = clone $this; + $clone->path = $this->path . '/' . $part; + return $clone; + } + + public function toHordeUrl(): \Horde\Url\Url + { + return new \Horde\Url\Url($this->path); + } + public function getScheme(): string + { + return ''; + } + public function getAuthority(): string + { + return ''; + } + public function getUserInfo(): string + { + return ''; + } + public function getHost(): string + { + return ''; + } + public function getPort(): ?int + { + return null; + } + public function getPath(): string + { + return $this->path; + } + public function getQuery(): string + { + return ''; + } + public function getFragment(): string + { + return ''; + } + public function withScheme(string $scheme): static + { + return $this; + } + public function withUserInfo(string $user, ?string $password = null): static + { + return $this; + } + public function withHost(string $host): static + { + return $this; + } + public function withPort(?int $port): static + { + return $this; + } + public function withPath(string $path): static + { + $c = clone $this; + $c->path = $path; + return $c; + } + public function withQuery(string $query): static + { + return $this; + } + public function withFragment(string $fragment): static + { + return $this; + } + public function __toString(): string + { + return $this->path; + } + }; + } +} diff --git a/test/Unit/PageOutput/DesktopChromeRendererTest.php b/test/Unit/PageOutput/DesktopChromeRendererTest.php index 5ce36bec..16b28485 100644 --- a/test/Unit/PageOutput/DesktopChromeRendererTest.php +++ b/test/Unit/PageOutput/DesktopChromeRendererTest.php @@ -5,6 +5,7 @@ namespace Horde\Core\Test\Unit\PageOutput; use Horde\Core\Assets\JsDiscoverer; +use Horde\Core\Assets\JsDiscoveryResult; use Horde\Core\PageOutput\AssetCollector; use Horde\Core\PageOutput\DesktopChromeRenderer; use Horde\Core\PageOutput\PageComposer; @@ -98,9 +99,20 @@ private function defaultJsDiscoverer(): MockObject { $mock = $this->createMock(JsDiscoverer::class); $mock->expects($this->never())->method('resolve'); + $this->stubDiscoverTheme($mock); return $mock; } + /** + * The renderer always asks for theme scripts; stub an empty result since + * JsDiscoveryResult is final and cannot be auto-generated by the mock. + */ + private function stubDiscoverTheme(MockObject $mock): void + { + $mock->method('discoverTheme') + ->willReturn(new JsDiscoveryResult([], 'default', 'horde')); + } + private function createRequest(RenderingMode $mode = RenderingMode::DYNAMIC): ServerRequest { $request = new ServerRequest('GET', 'http://localhost/horde/', [], null, '1.1', []); @@ -208,6 +220,7 @@ public function renderPageResolvesExtraJsFiles(): void ->method('resolve') ->with('tasks.js', 'nag') ->willReturn('/js/nag/tasks.js'); + $this->stubDiscoverTheme($jsDiscoverer); $renderer = $this->createRenderer( collector: $collector, diff --git a/test/Unit/PageOutput/ResponsiveChromeRendererTest.php b/test/Unit/PageOutput/ResponsiveChromeRendererTest.php index bc53b215..e0642692 100644 --- a/test/Unit/PageOutput/ResponsiveChromeRendererTest.php +++ b/test/Unit/PageOutput/ResponsiveChromeRendererTest.php @@ -8,6 +8,7 @@ use Horde\Core\Assets\CssDiscoverer; use Horde\Core\Assets\CssDiscoveryResult; use Horde\Core\Assets\JsDiscoverer; +use Horde\Core\Assets\JsDiscoveryResult; use Horde\Core\PageOutput\PageContent; use Horde\Core\PageOutput\RenderingMode; use Horde\Core\PageOutput\ResponsiveChromeRenderer; @@ -51,10 +52,21 @@ private function defaultJsDiscoverer(): MockObject ->willReturnCallback(function (string $file) { return '/js/' . $file; }); + $this->stubDiscoverTheme($mock); return $mock; } + /** + * The renderer always asks for theme scripts; stub an empty result since + * JsDiscoveryResult is final and cannot be auto-generated by the mock. + */ + private function stubDiscoverTheme(MockObject $mock): void + { + $mock->method('discoverTheme') + ->willReturn(new JsDiscoveryResult([], 'default', 'horde')); + } + private function createRenderer( ?MockObject $cssDiscoverer = null, ?MockObject $jsDiscoverer = null, @@ -201,6 +213,7 @@ public function renderPageHandlesNoJsResolutions(): void $jsDiscoverer = $this->createMock(JsDiscoverer::class); $jsDiscoverer->expects($this->atLeastOnce()) ->method('resolve')->willReturn(null); + $this->stubDiscoverTheme($jsDiscoverer); $renderer = $this->createRenderer(jsDiscoverer: $jsDiscoverer); $content = new PageContent(title: 'Test', bodyHtml: ''); diff --git a/test/Unit/PageOutput/ViewModeConfiguratorDiscovererTest.php b/test/Unit/PageOutput/ViewModeConfiguratorDiscovererTest.php index 1e0cf771..dc2d981f 100644 --- a/test/Unit/PageOutput/ViewModeConfiguratorDiscovererTest.php +++ b/test/Unit/PageOutput/ViewModeConfiguratorDiscovererTest.php @@ -5,6 +5,8 @@ namespace Horde\Core\Test\Unit\PageOutput; use Horde\Core\Assets\JsDiscoverer; +use Horde\Core\Assets\JsDiscoveryRequest; +use Horde\Core\Assets\JsDiscoveryResult; use Horde\Core\PageOutput\AssetCollector; use Horde\Core\PageOutput\ViewMode; use Horde\Core\PageOutput\ViewModeConfigurator; @@ -43,6 +45,11 @@ public function resolveMany(array $files, string $app = 'horde'): array } return $result; } + + public function discoverTheme(JsDiscoveryRequest $request): JsDiscoveryResult + { + return new JsDiscoveryResult([], $request->theme, $request->app); + } }; } @@ -225,6 +232,11 @@ public function resolveMany(array $files, string $app = 'horde'): array $this->apps[] = $app; return array_combine($files, array_map(fn($f) => '/js/' . $f, $files)); } + + public function discoverTheme(JsDiscoveryRequest $request): JsDiscoveryResult + { + return new JsDiscoveryResult([], $request->theme, $request->app); + } }; // BASIC mode without an authenticated user only consults the session. diff --git a/test/Unit/Sidebar/SidebarRendererScriptRegistrationTest.php b/test/Unit/Sidebar/SidebarRendererScriptRegistrationTest.php index d499536f..6bb01d96 100644 --- a/test/Unit/Sidebar/SidebarRendererScriptRegistrationTest.php +++ b/test/Unit/Sidebar/SidebarRendererScriptRegistrationTest.php @@ -5,6 +5,8 @@ namespace Horde\Core\Test\Unit\Sidebar; use Horde\Core\Assets\JsDiscoverer; +use Horde\Core\Assets\JsDiscoveryRequest; +use Horde\Core\Assets\JsDiscoveryResult; use Horde\Core\PageOutput\AssetCollector; use Horde\Core\Sidebar\SidebarContainer; use Horde\Core\Sidebar\SidebarData; @@ -42,6 +44,11 @@ public function resolveMany(array $files, string $app = 'horde'): array } return $result; } + + public function discoverTheme(JsDiscoveryRequest $request): JsDiscoveryResult + { + return new JsDiscoveryResult([], $request->theme, $request->app); + } }; } diff --git a/test/Unit/Topbar/TopbarRendererScriptRegistrationTest.php b/test/Unit/Topbar/TopbarRendererScriptRegistrationTest.php index d2bf0ed3..81cce032 100644 --- a/test/Unit/Topbar/TopbarRendererScriptRegistrationTest.php +++ b/test/Unit/Topbar/TopbarRendererScriptRegistrationTest.php @@ -5,6 +5,8 @@ namespace Horde\Core\Test\Unit\Topbar; use Horde\Core\Assets\JsDiscoverer; +use Horde\Core\Assets\JsDiscoveryRequest; +use Horde\Core\Assets\JsDiscoveryResult; use Horde\Core\PageOutput\AssetCollector; use Horde\Core\Topbar\TopbarData; use Horde\Core\Topbar\TopbarRenderer; @@ -41,6 +43,11 @@ public function resolveMany(array $files, string $app = 'horde'): array } return $result; } + + public function discoverTheme(JsDiscoveryRequest $request): JsDiscoveryResult + { + return new JsDiscoveryResult([], $request->theme, $request->app); + } }; }