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 = '';
+ 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(
[