diff --git a/extension.json b/extension.json index 9114d23..03bd5f1 100644 --- a/extension.json +++ b/extension.json @@ -48,10 +48,10 @@ }, "groups": { "bluelink": { - "image": "resources/lib/ooui/themes/wikimediaui/images/icons/article-rtl-progressive.svg" + "image": "cdxIconArticle" }, "redlink": { - "image": "resources/lib/ooui/themes/wikimediaui/images/icons/articleNotFound-ltr.svg", + "image": "cdxIconArticleNotFound", "color": { "border": "#ba0000", "highlight": { @@ -63,7 +63,7 @@ } }, "externallink": { - "image": "resources/lib/ooui/themes/wikimediaui/images/icons/linkExternal-ltr-progressive.svg", + "image": "cdxIconLinkExternal", "color": { "border": "grey", "highlight": { diff --git a/phpstan.neon b/phpstan.neon index 25d142d..db46a63 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,3 +8,11 @@ parameters: - ../../vendor bootstrapFiles: - ../../includes/AutoLoader.php + ignoreErrors: + # MW_INSTALL_PATH is defined by MediaWiki's entry-point bootstrap + # (index.php, api.php, maintenance scripts), not by AutoLoader.php. + # MW core itself uses this constant directly (see CodexModule.php); + # there is no equivalent on the Config service in MW 1.44+. + - + message: '#Constant MW_INSTALL_PATH not found\.#' + path: src/Extension.php diff --git a/psalm.xml b/psalm.xml index 7be1a42..b289355 100644 --- a/psalm.xml +++ b/psalm.xml @@ -32,5 +32,13 @@ + + + + + + diff --git a/resources/js/ApiPageConnectionRepo.js b/resources/js/ApiPageConnectionRepo.js index 662f2f9..4029054 100644 --- a/resources/js/ApiPageConnectionRepo.js +++ b/resources/js/ApiPageConnectionRepo.js @@ -176,7 +176,8 @@ module.ApiPageConnectionRepo = ( function ( mw, ApiConnectionsBuilder ) { for (let index in icons) { let icon = icons[index] if (icon.type === 'ooui') { - images[page.title] = 'resources/lib/ooui/themes/wikimediaui/images/icons/' + icon.icon; + images[page.title] = (mw.config.get('wgScriptPath') || '') + + '/resources/lib/ooui/themes/wikimediaui/images/icons/' + icon.icon; break } else if (icon.type === 'file') { fileIcons[page.title] = icon.icon; diff --git a/src/Extension.php b/src/Extension.php index 6354bd4..f2739fb 100644 --- a/src/Extension.php +++ b/src/Extension.php @@ -4,20 +4,34 @@ namespace MediaWiki\Extension\Network; +use MediaWiki\Extension\Network\NetworkFunction\IconResolver; use MediaWiki\Extension\Network\NetworkFunction\NetworkConfig; use MediaWiki\Extension\Network\NetworkFunction\NetworkPresenter; use MediaWiki\Extension\Network\NetworkFunction\NetworkUseCase; use MediaWiki\Extension\Network\NetworkFunction\ParserFunctionNetworkPresenter; use MediaWiki\Extension\Network\NetworkFunction\SpecialNetworkPresenter; +use MediaWiki\MainConfigNames; +use MediaWiki\MediaWikiServices; class Extension { + public function __construct( + private readonly IconResolver $iconResolver + ) { + } + public static function getFactory(): self { - return new self(); + $mainConfig = MediaWikiServices::getInstance()->getMainConfig(); + return new self( + new IconResolver( + MW_INSTALL_PATH . '/resources/lib/codex-icons/codex-icons.json', + $mainConfig->get( MainConfigNames::ScriptPath ) + ) + ); } public function newNetworkFunction( NetworkPresenter $presenter, NetworkConfig $config ): NetworkUseCase { - return new NetworkUseCase( $presenter, $config->getOptions() ); + return new NetworkUseCase( $presenter, $config->getOptions(), $this->iconResolver ); } public function newParserFunctionNetworkPresenter(): ParserFunctionNetworkPresenter { diff --git a/src/NetworkFunction/IconResolver.php b/src/NetworkFunction/IconResolver.php new file mode 100644 index 0000000..60daeb7 --- /dev/null +++ b/src/NetworkFunction/IconResolver.php @@ -0,0 +1,108 @@ + $group ) { + if ( is_array( $group ) && isset( $group['image'] ) && is_string( $group['image'] ) ) { + $visJsOptions['groups'][$name]['image'] = $this->resolveImage( $group['image'] ); + } + } + return $visJsOptions; + } + + private function resolveImage( string $value ): string { + if ( preg_match( '/^cdxIcon[A-Z]/', $value ) === 1 ) { + $resolved = $this->codexIconToDataUri( $value ); + if ( $resolved === null ) { + LoggerFactory::getInstance( 'Network' )->warning( + 'Unknown or unsupported Codex icon "{name}" in PageNetworkOptions; ' + . 'falling back to literal value (will likely fail to load).', + [ 'name' => $value ] + ); + return $value; + } + return $resolved; + } + if ( $this->isAbsoluteUrl( $value ) ) { + return $value; + } + return $this->scriptPath . '/' . $value; + } + + private function isAbsoluteUrl( string $value ): bool { + return str_starts_with( $value, 'http://' ) + || str_starts_with( $value, 'https://' ) + || str_starts_with( $value, '//' ) + || str_starts_with( $value, '/' ) + || str_starts_with( $value, 'data:' ); + } + + private function codexIconToDataUri( string $iconName ): ?string { + $path = $this->extractPath( $this->loadIcons()[$iconName] ?? null ); + if ( $path === null ) { + return null; + } + // Codex icons are authored on a 20x20 grid. + // See https://doc.wikimedia.org/codex/main/style-guide/icons.html + $svg = '' + . $path + . ''; + return 'data:image/svg+xml;base64,' . base64_encode( $svg ); + } + + /** + * codex-icons.json stores entries in three shapes: + * - a plain string of SVG path markup (e.g. cdxIconAdd) + * - an associative array with an 'ltr' key (e.g. cdxIconArticle) + * - an associative array with a 'default' key plus a 'langCodeMap' (e.g. cdxIconBold) + * + * Returns the SVG path markup for any of these, preferring 'ltr' when available + * and falling back to 'default'. Returns null for shapes we don't understand. + */ + private function extractPath( mixed $icon ): ?string { + if ( is_string( $icon ) ) { + return $icon; + } + if ( !is_array( $icon ) ) { + return null; + } + if ( isset( $icon['ltr'] ) && is_string( $icon['ltr'] ) ) { + return $icon['ltr']; + } + if ( isset( $icon['default'] ) && is_string( $icon['default'] ) ) { + return $icon['default']; + } + return null; + } + + private function loadIcons(): array { + if ( $this->iconData === null ) { + if ( is_readable( $this->codexIconsJsonPath ) ) { + $json = file_get_contents( $this->codexIconsJsonPath ); + $this->iconData = ( $json !== false ? json_decode( $json, true ) : null ) ?? []; + } else { + $this->iconData = []; + } + } + return $this->iconData; + } + +} diff --git a/src/NetworkFunction/NetworkUseCase.php b/src/NetworkFunction/NetworkUseCase.php index 42add7a..a0ab41c 100644 --- a/src/NetworkFunction/NetworkUseCase.php +++ b/src/NetworkFunction/NetworkUseCase.php @@ -8,7 +8,8 @@ class NetworkUseCase { public function __construct( private readonly NetworkPresenter $presenter, - private readonly array $visJsOptions + private readonly array $visJsOptions, + private readonly IconResolver $iconResolver ) { } @@ -65,7 +66,7 @@ private function getVisJsOptions( array $arguments ): array { $this->visJsOptions, json_decode( $arguments['options'] ?? '{}', true ) ?? [] ); - return $visJsOptions; + return $this->iconResolver->resolve( $visJsOptions ); } /** diff --git a/tests/php/NetworkFunction/IconResolverTest.php b/tests/php/NetworkFunction/IconResolverTest.php new file mode 100644 index 0000000..22b42a2 --- /dev/null +++ b/tests/php/NetworkFunction/IconResolverTest.php @@ -0,0 +1,206 @@ + [ + 'ltr' => '', + 'shouldFlip' => true, + ], + 'cdxIconLinkExternal' => [ + 'ltr' => '', + ], + // Plain-string shape (155 of MW's bundled icons use this form, e.g. cdxIconAdd). + 'cdxIconAdd' => '', + // {default, langCodeMap} shape (e.g. cdxIconBold). + 'cdxIconBold' => [ + 'default' => '', + 'langCodeMap' => [ 'en' => 'assertStringContainsString( '', $decoded ); + } + + public function testStringShapeCodexIconBecomesDataUri(): void { + $result = $this->newResolver()->resolve( [ + 'groups' => [ 'g' => [ 'image' => 'cdxIconAdd' ] ], + ] ); + + $image = $result['groups']['g']['image']; + $this->assertStringStartsWith( 'data:image/svg+xml;base64,', $image ); + + $decoded = base64_decode( substr( $image, strlen( 'data:image/svg+xml;base64,' ) ) ); + $this->assertStringContainsString( '', $decoded ); + } + + public function testDefaultShapeCodexIconBecomesDataUri(): void { + $result = $this->newResolver()->resolve( [ + 'groups' => [ 'g' => [ 'image' => 'cdxIconBold' ] ], + ] ); + + $image = $result['groups']['g']['image']; + $this->assertStringStartsWith( 'data:image/svg+xml;base64,', $image ); + + $decoded = base64_decode( substr( $image, strlen( 'data:image/svg+xml;base64,' ) ) ); + $this->assertStringContainsString( '', $decoded ); + } + + public function testUnknownCodexIconNameLeftUnchanged(): void { + $result = $this->newResolver()->resolve( [ + 'groups' => [ 'bluelink' => [ 'image' => 'cdxIconNonExistent' ] ], + ] ); + + $this->assertSame( 'cdxIconNonExistent', $result['groups']['bluelink']['image'] ); + } + + public function testHttpsUrlLeftUnchanged(): void { + $result = $this->newResolver()->resolve( [ + 'groups' => [ 'g' => [ 'image' => 'https://example.com/foo.svg' ] ], + ] ); + + $this->assertSame( 'https://example.com/foo.svg', $result['groups']['g']['image'] ); + } + + public function testProtocolRelativeUrlLeftUnchanged(): void { + $result = $this->newResolver()->resolve( [ + 'groups' => [ 'g' => [ 'image' => '//example.com/foo.svg' ] ], + ] ); + + $this->assertSame( '//example.com/foo.svg', $result['groups']['g']['image'] ); + } + + public function testRootRelativeUrlLeftUnchanged(): void { + $result = $this->newResolver()->resolve( [ + 'groups' => [ 'g' => [ 'image' => '/wiki/foo.svg' ] ], + ] ); + + $this->assertSame( '/wiki/foo.svg', $result['groups']['g']['image'] ); + } + + public function testDataUriLeftUnchanged(): void { + $dataUri = 'data:image/svg+xml;base64,PHN2Zy8+'; + $result = $this->newResolver()->resolve( [ + 'groups' => [ 'g' => [ 'image' => $dataUri ] ], + ] ); + + $this->assertSame( $dataUri, $result['groups']['g']['image'] ); + } + + public function testRelativePathPrefixedWithScriptPath(): void { + $result = $this->newResolver( '/w' )->resolve( [ + 'groups' => [ 'g' => [ 'image' => 'resources/lib/ooui/foo.svg' ] ], + ] ); + + $this->assertSame( '/w/resources/lib/ooui/foo.svg', $result['groups']['g']['image'] ); + } + + public function testEmptyScriptPathStillProducesAbsolute(): void { + $result = $this->newResolver( '' )->resolve( [ + 'groups' => [ 'g' => [ 'image' => 'resources/lib/ooui/foo.svg' ] ], + ] ); + + $this->assertSame( '/resources/lib/ooui/foo.svg', $result['groups']['g']['image'] ); + } + + public function testNoGroupsKeyTolerated(): void { + $input = [ 'nodes' => [ 'shape' => 'image' ] ]; + $this->assertSame( $input, $this->newResolver()->resolve( $input ) ); + } + + public function testGroupsNotArrayTolerated(): void { + $input = [ 'groups' => 'not-an-array' ]; + $this->assertSame( $input, $this->newResolver()->resolve( $input ) ); + } + + public function testGroupWithoutImageTolerated(): void { + $input = [ + 'groups' => [ + 'bluelink' => [ 'color' => 'blue' ], + ], + ]; + $this->assertSame( $input, $this->newResolver()->resolve( $input ) ); + } + + public function testNonStringImageTolerated(): void { + $input = [ + 'groups' => [ + 'bluelink' => [ 'image' => 123 ], + ], + ]; + $this->assertSame( $input, $this->newResolver()->resolve( $input ) ); + } + + public function testMultipleGroupsResolvedIndependently(): void { + $result = $this->newResolver( '/w' )->resolve( [ + 'groups' => [ + 'bluelink' => [ 'image' => 'cdxIconArticle' ], + 'redlink' => [ 'image' => 'resources/foo.svg' ], + 'externallink' => [ 'image' => 'cdxIconLinkExternal' ], + 'fallback' => [ 'image' => 'https://example.com/x.svg' ], + ], + ] ); + + $this->assertStringStartsWith( 'data:image/svg+xml;base64,', $result['groups']['bluelink']['image'] ); + $this->assertSame( '/w/resources/foo.svg', $result['groups']['redlink']['image'] ); + $this->assertStringStartsWith( 'data:image/svg+xml;base64,', $result['groups']['externallink']['image'] ); + $this->assertSame( 'https://example.com/x.svg', $result['groups']['fallback']['image'] ); + } + + public function testMissingIconsJsonFileTolerated(): void { + $resolver = new IconResolver( '/path/that/does/not/exist.json', '/w' ); + $result = $resolver->resolve( [ + 'groups' => [ 'bluelink' => [ 'image' => 'cdxIconArticle' ] ], + ] ); + + $this->assertSame( 'cdxIconArticle', $result['groups']['bluelink']['image'] ); + } + + public function testMalformedIconsJsonFileTolerated(): void { + file_put_contents( $this->fixturePath, 'not valid json {{{' ); + $result = $this->newResolver()->resolve( [ + 'groups' => [ 'bluelink' => [ 'image' => 'cdxIconArticle' ] ], + ] ); + + $this->assertSame( 'cdxIconArticle', $result['groups']['bluelink']['image'] ); + } + +} diff --git a/tests/php/NetworkFunction/NetworkUseCaseTest.php b/tests/php/NetworkFunction/NetworkUseCaseTest.php index 70c64aa..db8a489 100644 --- a/tests/php/NetworkFunction/NetworkUseCaseTest.php +++ b/tests/php/NetworkFunction/NetworkUseCaseTest.php @@ -4,6 +4,7 @@ namespace MediaWiki\Extension\Network\Tests\NetworkFunction; +use MediaWiki\Extension\Network\NetworkFunction\IconResolver; use MediaWiki\Extension\Network\NetworkFunction\NetworkUseCase; use MediaWiki\Extension\Network\NetworkFunction\RequestModel; use PHPUnit\Framework\TestCase; @@ -15,6 +16,13 @@ class NetworkUseCaseTest extends TestCase { private const RENDERING_PAGE_NAME = 'MyPage'; + private function newNoOpIconResolver(): IconResolver { + // Pointed at a nonexistent JSON file so cdxIcon names won't resolve; + // none of the existing tests pass groups[*].image, so the resolver is + // effectively a no-op for them. + return new IconResolver( '/dev/null', '' ); + } + public function testDefaultPageName(): void { $this->assertSame( [ self::RENDERING_PAGE_NAME ], @@ -36,7 +44,7 @@ private function runAndReturnPresenter( RequestModel $requestModel ): SpyNetwork ] ] ]; - ( new NetworkUseCase( $presenter, $visJsOptions ) )->run( $requestModel ); + ( new NetworkUseCase( $presenter, $visJsOptions, $this->newNoOpIconResolver() ) )->run( $requestModel ); return $presenter; } @@ -177,7 +185,7 @@ public function testOverrideEnableDisplayTitleFalse(): void { public function testNoOptionsInLocalSettingsAndNoOptionsParameter(): void { $presenter = new SpyNetworkPresenter(); - ( new NetworkUseCase( $presenter, [] ) )->run( $this->newBasicRequestModel() ); + ( new NetworkUseCase( $presenter, [], $this->newNoOpIconResolver() ) )->run( $this->newBasicRequestModel() ); $this->assertSame( [], @@ -194,7 +202,7 @@ public function testOptionsInLocalSettings(): void { ]; $presenter = new SpyNetworkPresenter(); - ( new NetworkUseCase( $presenter, $setting ) )->run( $this->newBasicRequestModel() ); + ( new NetworkUseCase( $presenter, $setting, $this->newNoOpIconResolver() ) )->run( $this->newBasicRequestModel() ); $this->assertEquals( $setting, @@ -207,7 +215,7 @@ public function testOptionsParameter(): void { $request->functionArguments = [ 'options={"nodes": {"shape": "box"}}' ]; $presenter = new SpyNetworkPresenter(); - ( new NetworkUseCase( $presenter, [] ) )->run( $request ); + ( new NetworkUseCase( $presenter, [], $this->newNoOpIconResolver() ) )->run( $request ); $this->assertSame( [ @@ -232,7 +240,7 @@ public function testOptionsParameterWithLocalSettingsConfig(): void { $request->functionArguments = [ 'options={"nodes": {"shape": "box", "color": "red"}}' ]; $presenter = new SpyNetworkPresenter(); - ( new NetworkUseCase( $presenter, $setting ) )->run( $request ); + ( new NetworkUseCase( $presenter, $setting, $this->newNoOpIconResolver() ) )->run( $request ); $this->assertEquals( [