Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.52% covered (warning)
52.52%
125 / 238
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikifunctionsPFragmentHandler
52.52% covered (warning)
52.52%
125 / 238
33.33% covered (danger)
33.33%
3 / 9
240.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 sourceToFragment
57.89% covered (warning)
57.89%
55 / 95
0.00% covered (danger)
0.00%
0 / 1
22.75
 fillEmptyArgsWithDefaultValues
69.44% covered (warning)
69.44%
25 / 36
0.00% covered (danger)
0.00%
0 / 1
14.45
 fetchFunctionFromWikifunctionsApi
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
30
 extractWikifunctionCallArguments
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
7
 convertPFragmentToZFunctionCallParameter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSanitisedHtmlFragment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 decodeHtmlFragmentValue
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 createErrorfulFragment
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * WikiLambda extension Parsoid handler for our parser function
5 *
6 * @file
7 * @ingroup Extensions
8 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
9 * @license MIT
10 */
11
12namespace MediaWiki\Extension\WikiLambda\ParserFunction;
13
14use MediaWiki\Config\Config;
15use MediaWiki\Extension\WikiLambda\Jobs\WikifunctionsClientRequestJob;
16use MediaWiki\Extension\WikiLambda\Jobs\WikifunctionsClientUsageUpdateJob;
17use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
18use MediaWiki\Extension\WikiLambda\WikifunctionsClientStore;
19use MediaWiki\Extension\WikiLambda\WikiLambdaServices;
20use MediaWiki\Extension\WikiLambda\ZObjectUtils;
21use MediaWiki\Html\Html;
22use MediaWiki\Http\HttpRequestFactory;
23use MediaWiki\JobQueue\JobQueueGroup;
24use MediaWiki\Logger\LoggerFactory;
25use MediaWiki\MediaWikiServices;
26use MediaWiki\Parser\ParserOutput;
27use MediaWiki\Registration\ExtensionRegistry;
28use MediaWiki\WikiMap\WikiMap;
29use Psr\Log\LoggerInterface;
30use Wikimedia\Parsoid\Ext\Arguments;
31use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
32use Wikimedia\Parsoid\Ext\PFragmentHandler;
33use Wikimedia\Parsoid\Fragments\HtmlPFragment;
34use Wikimedia\Parsoid\Fragments\PFragment;
35use Wikimedia\Stats\Metrics\NullMetric;
36use Wikimedia\Stats\Metrics\TimingMetric;
37
38class WikifunctionsPFragmentHandler extends PFragmentHandler {
39
40    /**
41     * @var WikifunctionsClientStore (but not explicitly typed, as this is a service mocked in tests)
42     */
43    private $wikifunctionsClientStore;
44    private LoggerInterface $logger;
45    /**
46     * @var TimingMetric|NullMetric (might be a NullMetric in some circumstances)
47     */
48    private $statsFactoryTimer;
49
50    public function __construct(
51        private readonly Config $config,
52        private readonly JobQueueGroup $jobQueueGroup,
53        private readonly HttpRequestFactory $httpRequestFactory
54    ) {
55        $this->wikifunctionsClientStore = WikiLambdaServices::getWikifunctionsClientStore();
56        $this->logger = LoggerFactory::getInstance( 'WikiLambdaClient' );
57
58        $this->statsFactoryTimer = MediaWikiServices::getInstance()->getStatsFactory()
59            // Will end up as 'mediawiki.WikiLambdaClient.parsoid_to_fragment_handler_seconds{response=…}'
60            ->withComponent( 'WikiLambdaClient' )
61            ->getTiming( 'parsoid_to_fragment_handler_seconds' )
62            ->setLabel( 'wiki', WikiMap::getCurrentWikiId() );
63    }
64
65    /**
66     * @inheritDoc
67     */
68    public function sourceToFragment( ParsoidExtensionAPI $extApi, Arguments $callArgs, bool $tagSyntax ) {
69        $this->statsFactoryTimer->start();
70
71        // Note: We can't hint this as `: PFragment|AsyncResult` as we're still in PHP 7.4-land
72        // If client mode isn't enabled on this wiki, there's nothing to do, just show an error message
73        if ( !$this->config->get( 'WikiLambdaEnableClientMode' ) ) {
74            // TODO: Make this a proper error box or inline error.
75            $errorMsgString = wfMessage(
76                'wikilambda-functioncall-error-message',
77                [
78                    wfMessage( 'wikilambda-functioncall-error-disabled' )->text()
79                ]
80            )->text();
81
82            $this->statsFactoryTimer->setLabel( 'response', 'disabled' )->stop();
83            return WikifunctionsPFragment::newFromLiteral( $errorMsgString, null );
84        }
85
86        // Extract arguments:
87        $expansion = $this->extractWikifunctionCallArguments( $extApi, $callArgs );
88
89        // Fill empty arguments with default values:
90        $expansion = $this->fillEmptyArgsWithDefaultValues( $extApi, $expansion );
91
92        // On Wikibase client wikis, loop over each argument, in case it's a Wikidata item reference,
93        // and mark us as a user of said item if so. Doing this after default value filling, in case
94        // the default value ends up being a Wikidata item reference (e.g. item for current page).
95        if ( ExtensionRegistry::getInstance()->isLoaded( 'WikibaseClient' ) ) {
96            // Note: An absolute reference to avoid CI issues with references to unknown classes.
97            $wikibaseEntityParser = \Wikibase\Client\WikibaseClient::getEntityIdParser();
98
99            $parserOutputProvider = new ParsoidWrappingParserOutputProvider( $extApi->getMetadata() );
100            $usageAccumulator = \Wikibase\Client\WikibaseClient::getUsageAccumulatorFactory()
101                ->newFromParserOutputProvider( $parserOutputProvider );
102
103            foreach ( $expansion['arguments'] as $key => $value ) {
104                if (
105                    ZObjectUtils::isValidId( $value )
106                    // Short-cut to skip ZObject references which are Wikidata-esque
107                    && !ZObjectUtils::isValidZObjectReference( $value )
108                ) {
109                    try {
110                        // Convert the string into a Wikidata `EntityId`
111                        $itemId = $wikibaseEntityParser->parse( $value );
112
113                        // TODO (T385631): Only track some usage, somehow?
114                        $usageAccumulator->addAllUsage( $itemId );
115
116                    } catch ( \Wikibase\DataModel\Entity\EntityIdParsingException ) {
117                        // Not a valid Wikidata reference (e.g. "X123"), so treat as a string.
118                    }
119                }
120            }
121        }
122
123        $this->logger->debug(
124            'WikiLambda client call made for {function} on {page}',
125            [
126                'function' => $expansion['target'],
127                'page' => $extApi->getPageConfig()->getLinkTarget()->__toString()
128            ]
129        );
130
131        // (T362256): This is the key we use to cache on the client wiki code here, rather than only at the repo wiki.
132        $clientCacheKey = $this->wikifunctionsClientStore->makeFunctionCallCacheKey( $expansion );
133
134        // Schedule a job to update the usage tracking to say that we use this function on this page.
135        // We clear out the tracking each time the page is saved, via onPageSaveComplete above.
136
137        // FIXME: This will run whether or not we're a saved edit, or just a stash/edit preview. Fix by moving
138        // to page properties, which are only stored for the current revision?
139        $usageJob = new WikifunctionsClientUsageUpdateJob( [
140            'targetFunction' => $expansion['target'],
141            'targetPageText' => $extApi->getPageConfig()->getLinkTarget()->getDBkey(),
142            'targetPageNamespace' => $extApi->getPageConfig()->getLinkTarget()->getNamespace()
143        ] );
144        $this->jobQueueGroup->lazyPush( $usageJob );
145
146        // (T414848) Set a special flag on the page, so that we can track usage of pages with function calls, and find
147        // pages that use a lot of them.
148        // Note: Page properties must be strings for Parsoid's StubMetadataCollector to be happy; this also works IRL.
149        $newWikifunctionsUseCount = strval( intval(
150            // @phan-suppress-next-line PhanUndeclaredMethod — ContentMetadataCollector interface lacks, but we have it
151            $extApi->getMetadata()->getPageProperty( 'wikilambda' ) ?? 0
152        ) + 1 );
153        $extApi->getMetadata()->setNumericPageProperty( 'wikilambda', $newWikifunctionsUseCount );
154
155        // (T414848) Also track specifically usage of our target ZID
156        $targetFunctionPageProp = 'wikilambda-' . $expansion['target'];
157        $newTargetUseCount = strval( intval(
158            // @phan-suppress-next-line PhanUndeclaredMethod — ContentMetadataCollector interface lacks, but we have it
159            $extApi->getMetadata()->getPageProperty( $targetFunctionPageProp ) ?? 0
160         ) + 1 );
161        $extApi->getMetadata()->setNumericPageProperty( $targetFunctionPageProp, $newTargetUseCount );
162
163        // Add our special reference code and style modules to the page, we know they're likely to be used somewhere
164        $extApi->getMetadata()->addModules( [ 'ext.wikilambda.references' ] );
165        $extApi->getMetadata()->addModuleStyles( [ 'ext.wikilambda.references.styles' ] );
166
167        $cachedValue = $this->wikifunctionsClientStore->fetchFromFunctionCallCache( $clientCacheKey );
168
169        if ( $cachedValue ) {
170            // Good news, this request has already been cached; examine what it is
171
172            if ( $cachedValue['success'] === true ) {
173                // It was a successful run!
174                // If output type is Z89, return as HTML fragment;
175                if (
176                    isset( $cachedValue['type'] )
177                    && $cachedValue['type'] === ZTypeRegistry::Z_HTML_FRAGMENT
178                ) {
179                    $this->statsFactoryTimer->setLabel( 'response', 'cached' )->stop();
180                    $html = $this->getSanitisedHtmlFragment( $cachedValue['value'] ?? '' );
181                    return HtmlPFragment::newFromHtmlString( $html, null );
182                }
183                // Otherwise, return as literal
184                $this->statsFactoryTimer->setLabel( 'response', 'cached' )->stop();
185                return WikifunctionsPFragment::newFromLiteral( $cachedValue['value'] ?? '', null );
186            }
187
188            // It failed for some reason; show the error message instead
189            $errorMessageKey = $cachedValue['errorMessageKey'];
190
191            $extApi->addTrackingCategory( $errorMessageKey . '-category' );
192
193            $this->logger->info(
194                'WikiLambda client request failed, returned {error} for request to {targetFunction} on {page}',
195                [
196                    'error' => $errorMessageKey,
197                    'targetFunction' => $expansion['target'],
198                    'page' => $extApi->getPageConfig()->getLinkTarget()->__toString()
199                ]
200            );
201
202            // Load ext.wikilambda.inlineerrors css
203            $extApi->getMetadata()->appendOutputStrings(
204                // @phan-suppress-next-line PhanTypeMismatchArgumentReal Parsoid's type hint should be updated
205                \MediaWiki\Parser\ParserOutputStringSets::MODULE,
206                [ 'ext.wikilambda.inlineerrors' ]
207            );
208
209            $this->statsFactoryTimer->setLabel( 'response', 'cachedError' )->stop();
210
211            return $this->createErrorfulFragment( $extApi, $errorMessageKey );
212        }
213
214        // At this point, we know our request hasn't yet been stored in the cache, so we need to trigger it,
215        // and return a placeholder for now
216
217        $this->logger->info(
218            'WikiLambda client request was uncached for request to {targetFunction} on {page}',
219            [
220                'targetFunction' => $expansion['target'],
221                'page' => $extApi->getPageConfig()->getLinkTarget()->__toString()
222            ]
223        );
224
225        // Check if SRE have set this wiki (probably all wikis) temporarily to not try to use Wikifunctions.
226        if ( $this->config->get( 'WikiLambdaClientModeOffline' ) ) {
227            $this->statsFactoryTimer->setLabel( 'response', 'offline' )->stop();
228            return HtmlPFragment::newFromHtmlString( Html::errorBox(
229                wfMessage( 'wikilambda-fragment-disabled' )->text()
230            ), null );
231        }
232
233        // This job triggers the request, will store the result in the cache. We don't pass in the location of
234        // the usage, as that's the responsibility of this class (to add tracking categories etc.) or of Parsoid
235        // (to purge the page once our fragment is available etc.).
236        $renderJob = new WikifunctionsClientRequestJob( [
237            'request' => $expansion,
238            'clientCacheKey' => $clientCacheKey,
239        ] );
240        $this->jobQueueGroup->lazyPush( $renderJob );
241
242        // As we're async, return a "sorry, no content yet" fragment
243        $this->statsFactoryTimer->setLabel( 'response', 'pending' )->stop();
244        return new WikifunctionsPendingFragment(
245            $extApi->getPageConfig()->getPageLanguageBcp47(), null
246        );
247    }
248
249    /**
250     * Sets empty arguments with their default value (if available)
251     *
252     * @param ParsoidExtensionAPI $extApi
253     * @param array $functionCall
254     * @return array
255     */
256    private function fillEmptyArgsWithDefaultValues( ParsoidExtensionAPI $extApi, array $functionCall ): array {
257        // 1. See if there's an empty string arg
258        // 2. If there's any:
259        //  2.1. fetch Function Zid from Memcached
260        //  2.2. fetch Function Zid from Wikifunctions
261        //  2.3. look into the argument key
262        //  2.4. check if it has a default value callback
263        //  2.5. generate default value
264        // 3. Then proceed, with new arg set for cache key, etc.
265        foreach ( $functionCall[ 'arguments' ] as $argKey => $argValue ) {
266            // If argValue is not empty, continue
267            if ( $argValue !== '' ) {
268                continue;
269            }
270
271            // 2.1. Fetch Function Zid from Memcached
272            $zobject = $this->wikifunctionsClientStore->fetchFromZObjectCache( $functionCall[ 'target' ] );
273            if ( !$zobject ) {
274                // 2.2. Fetch Function Zid from Wikifunctions
275                $this->logger->info(
276                    __METHOD__ . ' cache miss while fetching {zid} for empty argument {arg}, falling back to API',
277                    [
278                        'zid' => $functionCall[ 'target' ],
279                        'arg' => $argKey
280                    ]
281                );
282                $zobject = $this->fetchFunctionFromWikifunctionsApi( $functionCall[ 'target' ], $argKey );
283            }
284
285            // If function is not found, return on first iteration:
286            if ( !$zobject ) {
287                return $functionCall;
288            }
289
290            // If object is not a function, return on first iteration:
291            $function = $zobject[ ZTypeRegistry::Z_PERSISTENTOBJECT_VALUE ];
292            if ( $function[ ZTypeRegistry::Z_OBJECT_TYPE ] !== ZTypeRegistry::Z_FUNCTION ) {
293                return $functionCall;
294            }
295
296            // 2.3. Get argument type zid
297            $args = array_slice( $function[ ZTypeRegistry::Z_FUNCTION_ARGUMENTS ], 1 );
298            $matches = array_filter( $args, static function ( $item ) use ( $argKey ) {
299                return isset( $item[ ZTypeRegistry::Z_ARGUMENTDECLARATION_ID ] ) &&
300                    $item[ ZTypeRegistry::Z_ARGUMENTDECLARATION_ID ] === $argKey;
301            } );
302            $arg = reset( $matches ) ?: null;
303
304            // If argKey is not found, continue
305            if ( !$arg ) {
306                continue;
307            }
308
309            $argType = $arg[ ZTypeRegistry::Z_ARGUMENTDECLARATION_TYPE ];
310
311            // 2.4. Check if the argument type has a default value callback defined
312            if ( is_string( $argType ) && WikifunctionsCallDefaultValues::hasDefaultValueCallback( $argType ) ) {
313                $defaultValueCallback = WikifunctionsCallDefaultValues::getDefaultValueForType( $argType );
314                $defaultValueContext = [
315                    'contentMetadataCollector' => $extApi->getMetadata(),
316                    'linkTarget' => $extApi->getPageConfig()->getLinkTarget(),
317                    'pageLanguage' => $extApi->getPageConfig()->getPageLanguageBcp47()->toBcp47Code(),
318                ];
319                // 2.5. Generate the default value
320                $functionCall[ 'arguments' ][ $argKey ] = $defaultValueCallback( $defaultValueContext );
321            }
322        }
323
324        return $functionCall;
325    }
326
327    /**
328     * Requests the given function Zid from the Wikifunctions ActionAPI.
329     * Returns null if the function Zid is not found.
330     *
331     * @param string $zid
332     * @param string $argKey
333     * @return ?array
334     */
335    private function fetchFunctionFromWikifunctionsApi( $zid, $argKey ): ?array {
336        $requestParams = [
337            'action' => 'wikilambda_fetch',
338            'format' => 'json',
339            'zids' => $zid,
340            'formatversion' => 2,
341        ];
342        $baseUrl = WikifunctionsClientRequestJob::getClientTargetUrl( $this->config, $this->logger );
343        $apiUrl = wfAppendQuery( $baseUrl . wfScript( 'api' ), $requestParams );
344        $request = $this->httpRequestFactory->create( $apiUrl, [ 'method' => 'GET' ], __METHOD__ );
345
346        if ( !$request ) {
347            $this->logger->error(
348                __METHOD__ . ' failed to create a request to {url}',
349                [
350                    'url' => $apiUrl
351                ]
352            );
353            return null;
354        }
355
356        // Execute request:
357        $status = $request->execute();
358        $response = json_decode( $request->getContent() );
359
360        // Failed request:
361        if ( !$response || !$status->isOK() ) {
362            $httpStatusCode = $request->getStatus();
363            $this->logger->warning(
364                __METHOD__ . ' received error {status} while fetching {zid} for empty argument {arg}: {request}',
365                [
366                    'status' => $httpStatusCode,
367                    'zid' => $zid,
368                    'arg' => $argKey,
369                    'request' => $request->getFinalUrl()
370                ]
371            );
372            return null;
373        }
374
375        // Successful request:
376        $json = json_decode( $response->{ $zid }->wikilambda_fetch, true );
377        if ( !$json ) {
378            $this->logger->warning(
379                __METHOD__ . ' failed parsing the Json response to fetching    {zid} for empty argument {arg}: {request}',
380                [
381                    'zid' => $zid,
382                    'arg' => $argKey,
383                    'request' => $request->getFinalUrl()
384                ]
385            );
386        }
387
388        // Return successfully parsed Json or null
389        return $json;
390    }
391
392    /**
393     * Extracts the arguments from the wikitext and turn it into the request we'll need
394     *
395     * @param ParsoidExtensionAPI $extApi
396     * @param Arguments $arguments
397     */
398    private function extractWikifunctionCallArguments( $extApi, $arguments ): array {
399        // Get the arguments from the wikitext with the HTML entities decoded and with whitespace trimmed.
400        // E.g.:
401        // * unnamed arguments:
402        //   given an input of `{{#function:Z802 | Z41 | h&eacute;llõ |   1234}}`
403        //   $cleanedArgs will be: [ 'Z802', 'héllõ', '1234' ]
404        // * named arguments (renderlang and parselang):
405        //   given an input of `{{#function:Z802|Z41|hello|1234|renderlang=es}}`
406        //   $cleanedArgs will be: [ 'Z802', 'hello', '1234', 'renderlang=es' ]
407        // * trim all arguments except when arg is all whitespaces:
408        //   given an input of ``{{#function:Z15175| hello |world | }}
409        //   $cleanedArgs will be [ 'Z15175', 'hello', 'world', ' ' ]
410
411        // First call to getOrderedArgs with trim=false to find the only-whitespace arguments:
412        $rawArgs = $arguments->getOrderedArgs( $extApi, false );
413        $isOnlyWhitespace = array_map( static function ( $v ) use ( $extApi ) {
414            return trim( $v->toRawText( $extApi ) ) !== '';
415        }, array_values( $rawArgs ) );
416
417        // TODO (T390344): Switch to getNamedArgs() once Parsoid supports that
418        // All arguments are expanded and trimmed, except for those which are just a whitespace
419        $cleanedArgs = $arguments->getOrderedArgs( $extApi, $isOnlyWhitespace );
420
421        // Parse and render languages are set to Parsoid's page target language by default.
422        $parseLang = $extApi->getPageConfig()->getPageLanguageBcp47()->toBcp47Code();
423        $renderLang = $parseLang;
424
425        // We allow users to specify language in-line, e.g. if you want something copy-pastable
426        // or to demonstrate content in different languages. This is expected to be primarily useful for
427        // multi-lingual wikis.
428        // TODO (T390344): This won't work for now, we we only have ordered arguments, not named ones.
429        // if ( array_key_exists( 'parselang', $cleanedArgs ) && $cleanedArgs['parselang'] instanceof PFragment ) {
430        //     $parseLang = $cleanedArgs['parselang']->killMarkers();
431        // }
432        // if ( array_key_exists( 'renderlang', $cleanedArgs ) && $cleanedArgs['renderlang'] instanceof PFragment ) {
433        //     $renderLang = $cleanedArgs['renderlang']->killMarkers();
434        // }
435
436        // Get the target function from the first argument.
437        // e.g. given an input of `{{#function:Z802|Z41|héllõ|1234}}`, $cleanedArgs[0] will be: 'Z802'
438        $targetFunction = $cleanedArgs[0]->killMarkers();
439
440        // Convert the raw unnamed arguments into the keys for the function call.
441        // e.g. given an input of `{{#function:Z802|Z41|hello|1234}}`, $arguments will be:
442        // [ 'Z802K1' => 'Z41', 'Z802K2' => 'hello', 'Z802K3' => '1234' ]
443        $unkeyedArguments = array_slice( $cleanedArgs, 1 );
444        $arguments = [];
445        foreach ( $unkeyedArguments as $key => $value ) {
446            if ( !( $value instanceof PFragment ) ) {
447                // Ignore any non-PFragment arguments that have somehow snuck in (probably nulls?)
448                continue;
449            }
450
451            $valueText = $this->convertPFragmentToZFunctionCallParameter( $value, $extApi );
452            $argKey = $targetFunction . 'K' . ( $key + 1 );
453
454            // named argument (e.g. 1=hello, foo=bar, renderlang=en)
455            if ( strpos( $valueText, '=' ) !== false ) {
456                $argKeyParts = explode( '=', $valueText, 2 );
457                // key is not numeric:
458                // * renderlang=es (save as $renderLang)
459                // * parselang=es (save as $parseLang)
460                // * any=other (ignore)
461                if ( !is_numeric( $argKeyParts[0] ) ) {
462                    if ( $argKeyParts[0] === 'parselang' ) {
463                        $parseLang = $argKeyParts[1];
464                    } elseif ( $argKeyParts[0] === 'renderlang' ) {
465                        $renderLang = $argKeyParts[1];
466                    }
467                    continue;
468                }
469                // key is numeric:
470                // * trust the key, keep the value
471                $argKey = $targetFunction . 'K' . $argKeyParts[0];
472                $valueText = $argKeyParts[1];
473            }
474
475            $arguments[$argKey] = $valueText;
476        }
477
478        return [
479            'target' => $targetFunction,
480            'arguments' => $arguments,
481            'parseLang' => $parseLang,
482            'renderLang' => $renderLang
483        ];
484    }
485
486    /**
487     * Coerce an PFragment into a string to be used as a parameter in the ZObject function call.
488     *
489     * For now this is a trivial conversion, but in the future we may want to do smarter things (e.g. for
490     * whitespace handling, see T362251).
491     *
492     * @param PFragment $value
493     * @param ParsoidExtensionAPI $extApi
494     * @return string
495     */
496    private function convertPFragmentToZFunctionCallParameter( PFragment $value, ParsoidExtensionAPI $extApi ): string {
497        return $value->toRawText( $extApi );
498    }
499
500    /**
501     * Decode and sanitise a possibly JSON-encoded HTML fragment string.
502     *
503     * @param string $value
504     * @return string
505     */
506    private function getSanitisedHtmlFragment( string $value ): string {
507        $html = $this->decodeHtmlFragmentValue( $value );
508        return WikifunctionsPFragmentSanitiserTokenHandler::sanitiseHtmlFragment( $this->logger, $html );
509    }
510
511    /**
512     * Decode a possibly JSON-encoded HTML fragment string.
513     *
514     * @param string $value
515     * @return string
516     */
517    private function decodeHtmlFragmentValue( string $value ): string {
518        if ( $value === '' ) {
519            return '';
520        }
521        // Try to decode as JSON, but only use the result if it's a string
522        $decoded = json_decode( $value, true );
523        if ( is_string( $decoded ) ) {
524            return $decoded;
525        }
526        // If not JSON or not a string, return as-is
527        return $value;
528    }
529
530    /**
531     * Helper to create an error fragment for failed function calls.
532     */
533    private function createErrorfulFragment( ParsoidExtensionAPI $extApi, ?string $errorMessageKey ): HTMLPFragment {
534        $cmc = $extApi->getMetadata();
535        if ( $cmc instanceof ParserOutput ) {
536            // Make sure our fragment's cache expiry is set to at most 1 hour, as we're adding an errorful piece of
537            // content that is likely to be fixed next time around, but we don't want to slam the server.
538            $cmc->updateRuntimeAdaptiveExpiry( 60 * 60 );
539        }
540
541        // Codex will not support inline rendering of error chips or error messages, so we need to
542        // add inline styles to align it inline with the body text and to make it scale properly.
543        return HtmlPFragment::newFromHtmlString(
544            '<span class="cdx-info-chip cdx-info-chip--error"'
545                . 'style="position:relative;line-height: var(--line-height-medium, 1.375rem); padding-left:calc('
546                    . 'var(--font-size-medium, 1rem) + calc(var(--font-size-medium,1rem) - 6px));"'
547                . 'data-error-key="' . htmlspecialchars( $errorMessageKey ?? '' ) . '">'
548                . '<span class="cdx-info-chip__icon"'
549                    . 'style="position:absolute;left:calc((var(--font-size-medium,1rem) - 2px) * .5);"'
550                    . 'aria-hidden="true"></span>'
551                . '<span class="cdx-info-chip__text" style="font-size:var(--font-size-medium,1rem);">'
552                    . wfMessage( 'wikilambda-visualeditor-wikifunctionscall-error' )->text()
553                . '</span>'
554            . '</span>',
555            null
556        );
557    }
558}