Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
4.17% |
2 / 48 |
|
9.09% |
2 / 22 |
CRAP | |
0.00% |
0 / 1 |
| PFragment | |
4.17% |
2 / 48 |
|
9.09% |
2 / 22 |
1241.91 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| isEmpty | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| isAtomic | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| isValid | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getSrcOffsets | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| asDom | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| asHtmlString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| asMarkedWikitext | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| containsMarker | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| split | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| killMarkers | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| markerSkipCallback | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| trim | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| expand | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| toRawText | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| fromSplitWt | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
56 | |||
| joinSourceRange | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
| registerFragmentClass | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| toJsonArray | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| newFromJsonArray | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| jsonClassHintFor | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
| hint | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | declare( strict_types = 1 ); |
| 3 | |
| 4 | namespace Wikimedia\Parsoid\Fragments; |
| 5 | |
| 6 | use JsonException; |
| 7 | use Wikimedia\Assert\Assert; |
| 8 | use Wikimedia\JsonCodec\Hint; |
| 9 | use Wikimedia\JsonCodec\JsonCodecable; |
| 10 | use Wikimedia\JsonCodec\JsonCodecableTrait; |
| 11 | use Wikimedia\Parsoid\Core\DomSourceRange; |
| 12 | use Wikimedia\Parsoid\DOM\DocumentFragment; |
| 13 | use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI; |
| 14 | |
| 15 | /** |
| 16 | * A PFragment is a MediaWiki content fragment. |
| 17 | * |
| 18 | * PFragment is the input and output type for fragment generators in |
| 19 | * MediaWiki: magic variables, parser functions, templates, and |
| 20 | * extension tags. You can imagine that the `P` stands for "Parsoid", |
| 21 | * "Page", or "MediaWiki Content" but in reality it simply |
| 22 | * disambiguates this fragment type from the DOM DocumentFragment and |
| 23 | * any other fragments you might encounter. |
| 24 | * |
| 25 | * PFragment is an abstract class, and content is lazily converted to the |
| 26 | * form demanded by a consumer. Converting forms often loses information |
| 27 | * or introduces edge cases, so we avoid conversion to intermediate forms |
| 28 | * and defer conversion in general as late as possible. |
| 29 | * |
| 30 | * For example, in this invocation: |
| 31 | * {{1x|'''bold''' <nowiki>fragment</nowiki>}} |
| 32 | * |
| 33 | * If we were to flatten this "as string" (traditionally) we would |
| 34 | * lose the bold face and the <nowiki> would get tunneled as strip |
| 35 | * state. Alternatively we could ask for this "as a source string" |
| 36 | * which corresponds to the original "raw" form: "'''bold''' |
| 37 | * <nowiki>fragment</nowiki>", which is often used to pass literal |
| 38 | * arguments, bypassing wikitext processing. Or we could |
| 39 | * ask for the argument "as HTML" or "as DOM" in which case it would |
| 40 | * get parsed as wikitext and returned as |
| 41 | * `<b>bold</b> <span>fragment</span>`, either as a possibly-unbalanced |
| 42 | * string ("as HTML") or as a balanced DOM tree ("as DOM"). These |
| 43 | * transformations can be irreversible: once we've converted to one |
| 44 | * representation we can't always recover the others. |
| 45 | * |
| 46 | * But now consider if `{{1x|...}}` simply wants to return its argument: |
| 47 | * it doesn't need to force a specific representation, instead |
| 48 | * it can return the PFragment directly without losing information |
| 49 | * and allow the downstream customer to chose the type it prefers. |
| 50 | * This also works for composition: a composite PFragment can be |
| 51 | * defined which defers evaluation of its components until demanded, |
| 52 | * and then applies the appropriate composition operation depending |
| 53 | * on the demanded result. |
| 54 | * |
| 55 | * (WikitextPFragment is one such composite fragment type, which uses |
| 56 | * Parsoid to do the composition of wikitext and other fragments.) |
| 57 | * |
| 58 | * Parsoid defines only those fragment types relevant to itself, and |
| 59 | * defines conversions (`as*()` methods) only for those formats it |
| 60 | * needs for HTML rendering. Extensions should feel free to define |
| 61 | * their own fragment types: as long as they are JsonCodecable and |
| 62 | * define one of ::asDom() or ::asHtmlString() they will interoperate |
| 63 | * with Parsoid and other extensions, albeit possibly as an opaque |
| 64 | * strip marker. Consider returning a PFragment implementing the |
| 65 | * Arguments interface if you want to allow argument |
| 66 | * interpolation into other transclusions. |
| 67 | * |
| 68 | * For example, Wikifunctions might define a PFragment for ZObjects, |
| 69 | * which would allow nested wikifunction invocations to transfer |
| 70 | * ZObjects between themselves without conversion through wikitext. |
| 71 | * For example, given: |
| 72 | * {{#function:sum| {{#function:one}} }} |
| 73 | * then the `sum` function will be given a ZObjectPFragment containing |
| 74 | * the output of the `one` function, without forcing that value to |
| 75 | * serialize to a wikitext string and deserialize. With its special |
| 76 | * knowledge of the ZObjectPFragment type, Wikifunctions can use this |
| 77 | * to (say) preserve type information of the values. But if this |
| 78 | * same function is embedded into a wikitext template: |
| 79 | * {{1x| {{#function:one}} }} |
| 80 | * then the value will be converted to wikitext or DOM as appropriate |
| 81 | * and composed onto the page in that form. |
| 82 | */ |
| 83 | abstract class PFragment implements JsonCodecable { |
| 84 | use JsonCodecableTrait; |
| 85 | |
| 86 | /** |
| 87 | * The original wikitext source range for this fragment, or `null` for |
| 88 | * synthetic content that corresponds to no part of the original |
| 89 | * authored text. |
| 90 | */ |
| 91 | protected ?DomSourceRange $srcOffsets; |
| 92 | |
| 93 | /** |
| 94 | * Registry of known fragment types, used for serialization. |
| 95 | * @see ::registerFragmentClass() |
| 96 | * @var list<class-string<PFragment>> |
| 97 | */ |
| 98 | protected static array $FRAGMENT_TYPES = [ |
| 99 | WikitextPFragment::class, |
| 100 | HtmlPFragment::class, |
| 101 | DomPFragment::class, |
| 102 | LiteralStringPFragment::class, |
| 103 | // Internal fragment types |
| 104 | HeadingPFragment::class, |
| 105 | ExtTagPFragment::class, |
| 106 | ]; |
| 107 | |
| 108 | protected function __construct( ?DomSourceRange $srcOffsets ) { |
| 109 | $this->srcOffsets = $srcOffsets; |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * Returns true if this fragment is empty. This enables optimizations |
| 114 | * if implemented, but returns false by default. |
| 115 | */ |
| 116 | public function isEmpty(): bool { |
| 117 | return false; |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * Returns true if this fragment contains no wikitext elements; that is, |
| 122 | * if `::asMarkedWikitext()` given an empty strip state |
| 123 | * would return a single strip marker and add a single item to the |
| 124 | * strip state (representing $this). Otherwise, returns false. |
| 125 | */ |
| 126 | public function isAtomic(): bool { |
| 127 | // This is consistent with the default implementation of |
| 128 | // ::asMarkedWikitext() |
| 129 | return true; |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * As an optimization to avoid unnecessary copying, certain |
| 134 | * operations on fragments may be destructive or lead to aliasing. |
| 135 | * For ease of debugging, fragments so affected will return `false` |
| 136 | * from `::isValid()` and code is encouraged to assert the validity |
| 137 | * of fragments where convenient to do so. |
| 138 | * |
| 139 | * @see the $release parameter to `::asDom()` and `DomPFragment::concat`, |
| 140 | * but other PFragment types with mutable non-value types might also |
| 141 | * provide accessors with `$release` parameters that interact with |
| 142 | * fragment validity. |
| 143 | */ |
| 144 | public function isValid(): bool { |
| 145 | // By default, fragments are valid forever. |
| 146 | |
| 147 | // See DomPFragment for an example of a fragment which may become |
| 148 | // invalid. |
| 149 | return true; |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Return the region of the source document that corresponds to this |
| 154 | * fragment. |
| 155 | */ |
| 156 | public function getSrcOffsets(): ?DomSourceRange { |
| 157 | return $this->srcOffsets; |
| 158 | } |
| 159 | |
| 160 | /** |
| 161 | * Return the fragment as a (prepared and loaded) DOM |
| 162 | * DocumentFragment belonging to the Parsoid top-level document. |
| 163 | * |
| 164 | * If $release is true, then this PFragment will become invalid |
| 165 | * after this method returns. |
| 166 | * |
| 167 | * @note The default implementation of ::asDom() calls ::asHtmlString(). |
| 168 | * Subclassses must implement either ::asDom() or ::asHtmlString() |
| 169 | * to avoid infinite mutual recursion. |
| 170 | */ |
| 171 | public function asDom( ParsoidExtensionAPI $extApi, bool $release = false ): DocumentFragment { |
| 172 | return $extApi->htmlToDom( $this->asHtmlString( $extApi ) ); |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * Return the fragment as a string of HTML. This method is very |
| 177 | * similar to asDom() but also supports fragmentary and unbalanced |
| 178 | * HTML, and therefore composition may yield unexpected results. |
| 179 | * This is a common type in legacy MediaWiki code, but use in |
| 180 | * new code should be discouraged. Data attributes will be |
| 181 | * represented as inline attributes, which may be suboptimal. |
| 182 | * @note The default implementation of ::asHtmlString() calls ::asDom(). |
| 183 | * Subclassses must implement either ::asDom() or ::asHtmlString() |
| 184 | * to avoid infinite mutual recursion. |
| 185 | */ |
| 186 | public function asHtmlString( ParsoidExtensionAPI $extApi ): string { |
| 187 | return $extApi->domToHtml( $this->asDom( $extApi ), true ); |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * This method returns a "wikitext string" in the legacy format. |
| 192 | * Wikitext constructs will be parsed in the result. |
| 193 | * Constructs which are not representable in wikitext will be replaced |
| 194 | * with strip markers, and you will get a strip state which maps |
| 195 | * those markers back to PFragment objects. When you (for example) |
| 196 | * compose two marked strings and then ask for the result `asDom`, |
| 197 | * the strip markers in the marked strings will first be conceptually |
| 198 | * replaced with the PFragment from the StripState, and then |
| 199 | * the resulting interleaved strings and fragments will be composed. |
| 200 | */ |
| 201 | public function asMarkedWikitext( StripState $stripState ): string { |
| 202 | // By default just adds this fragment to the strip state and |
| 203 | // returns a strip marker. Non-atomic fragments can be |
| 204 | // more clever. |
| 205 | return $stripState->addWtItem( $this ); |
| 206 | } |
| 207 | |
| 208 | /** |
| 209 | * Returns true if this fragment contains some non-wikitext content. |
| 210 | */ |
| 211 | public function containsMarker(): bool { |
| 212 | // This is overridden in WikitextPFragment |
| 213 | return true; |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * Split this fragment at its strip markers and return an array |
| 218 | * which alternates between string items and PFragment items. |
| 219 | * The first and last items are guaranteed to be strings, and the |
| 220 | * array length is guaranteed to be odd and at least 1. |
| 221 | * @return list<string|PFragment> |
| 222 | */ |
| 223 | public function split(): array { |
| 224 | // This is overridden in WikitextPFragment |
| 225 | return [ '', $this, '' ]; |
| 226 | } |
| 227 | |
| 228 | /** |
| 229 | * Return a version of this fragment with all non-wikitext content |
| 230 | * removed. |
| 231 | * See Parser::killMarkers() and StripState::killMarkers() in core. |
| 232 | * @return string |
| 233 | */ |
| 234 | public function killMarkers(): string { |
| 235 | // This is overridden in WikitextPFragment |
| 236 | return ''; |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * Call a callback function on all regions of the given text that |
| 241 | * are wikitext content, replacing them with the return value of |
| 242 | * the callback. Non-wikitext content is skipped but included |
| 243 | * in their proper places. |
| 244 | * @param callable(string):string $callback |
| 245 | * @return PFragment |
| 246 | */ |
| 247 | public function markerSkipCallback( callable $callback ): PFragment { |
| 248 | // This is overridden in WikitextPFragment |
| 249 | return $this; |
| 250 | } |
| 251 | |
| 252 | /** |
| 253 | * Trim leading and trailing wikitext whitespace from this fragment. |
| 254 | * |
| 255 | * For non-wikitext fragments, this will typically return the |
| 256 | * original fragment unmodified: it does *not* trim whitespace |
| 257 | * from within HTML strings or DOM nodes. For custom fragment |
| 258 | * types which can serialize to wikitext, this may trim whitespace |
| 259 | * from the wikitext serialization. |
| 260 | * |
| 261 | * @return PFragment |
| 262 | */ |
| 263 | public function trim(): PFragment { |
| 264 | // This is overridden in WikitextPFragment |
| 265 | return $this; |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Expand templates, extension tags, and parser functions in a fragment. |
| 270 | * |
| 271 | * Fragment values are typically provided as lazy arguments with delayed |
| 272 | * evaluation; see [[en:Lazy_evaluation]]. The ::expand() method will |
| 273 | * "demand" a value, expanding templates and in the process executing |
| 274 | * parser functions and extension tags, making the argument strict. |
| 275 | * The ::asDom() and ::asHtmlString() methods perform a similar |
| 276 | * strict-evaluation function in the process of rendering to DOM/HTML. |
| 277 | * |
| 278 | * Expansion is generally considered to be idempotent: expanding an |
| 279 | * expanded value should be a no-op. |
| 280 | * |
| 281 | * @see PPFrame::expand() in core |
| 282 | * @see frame:expandTemplate(), frame:getArgument():expand() in Scribunto |
| 283 | */ |
| 284 | public function expand( ParsoidExtensionAPI $extApi, ?bool &$error = null ): PFragment { |
| 285 | return $extApi->preprocessFragment( $this, $error ); |
| 286 | } |
| 287 | |
| 288 | /** |
| 289 | * Return the 'raw text' of this fragment. |
| 290 | * |
| 291 | * This helper method implements several conventions for passing |
| 292 | * raw text, including surrounding the desired text with <nowiki> |
| 293 | * (T390345). |
| 294 | */ |
| 295 | public function toRawText( ParsoidExtensionAPI $extApi ): string { |
| 296 | /* TODO T390345: This should expand the fragment and then: |
| 297 | * - If the trimmed result consists of a <nowiki>, then return the |
| 298 | * contents of that <nowiki> |
| 299 | * - If the trimmed result consists of a LiteralPFragment, then return |
| 300 | * the literal contents |
| 301 | * - Otherwise, return the result as wikitext with strip |
| 302 | * markers killed (legacy compat). |
| 303 | * |
| 304 | * Note that <nowiki> effectively decodes entities, so the result |
| 305 | * is not *exactly* raw text. Other methods can be used (for |
| 306 | * example, a PFragmentHandler which returns a LiteralPFragment) |
| 307 | * if we need literal treatment of `&`. |
| 308 | */ |
| 309 | return $this->asDom( $extApi )->textContent; |
| 310 | } |
| 311 | |
| 312 | /** |
| 313 | * Helper function to create a new fragment from a mixed array of |
| 314 | * strings and fragments. |
| 315 | * |
| 316 | * Unlike WikitextPFragment::newFromSplitWt() this method will not |
| 317 | * always return a WikitextPFragment; for example if only one |
| 318 | * non-empty piece is provided this method will just return that |
| 319 | * piece without casting it to a WikitextPFragment. |
| 320 | * |
| 321 | * @param list<string|PFragment> $pieces |
| 322 | */ |
| 323 | public static function fromSplitWt( array $pieces, ?DomSourceRange $srcOffset = null ): PFragment { |
| 324 | $result = []; |
| 325 | // Remove empty pieces |
| 326 | foreach ( $pieces as $p ) { |
| 327 | if ( $p === '' ) { |
| 328 | continue; |
| 329 | } |
| 330 | if ( $p instanceof PFragment && $p->isEmpty() ) { |
| 331 | continue; |
| 332 | } |
| 333 | $result[] = $p; |
| 334 | } |
| 335 | // Optimize! |
| 336 | if ( count( $result ) === 1 && $result[0] instanceof PFragment ) { |
| 337 | return $result[0]; |
| 338 | } |
| 339 | return WikitextPFragment::newFromSplitWt( $result, $srcOffset ); |
| 340 | } |
| 341 | |
| 342 | /** |
| 343 | * Helper function to append two source ranges. |
| 344 | */ |
| 345 | protected static function joinSourceRange( ?DomSourceRange $first, ?DomSourceRange $second ): ?DomSourceRange { |
| 346 | if ( $first === null || $second === null ) { |
| 347 | return null; |
| 348 | } |
| 349 | Assert::invariant( $first->source === $second->source, |
| 350 | "DSR sources incompatible" ); |
| 351 | return new DomSourceRange( |
| 352 | $first->start, $second->end, null, null, source: $first->source |
| 353 | ); |
| 354 | } |
| 355 | |
| 356 | // JsonCodec support |
| 357 | |
| 358 | /** |
| 359 | * Register a fragment type with the JSON deserialization code. |
| 360 | * |
| 361 | * The given class should have a static constant named TYPE_HINT |
| 362 | * which gives the unique string property name which will distinguish |
| 363 | * serialized fragments of the given class. |
| 364 | * @param class-string<PFragment> $className |
| 365 | */ |
| 366 | public static function registerFragmentClass( string $className ): void { |
| 367 | if ( !in_array( $className, self::$FRAGMENT_TYPES, true ) ) { |
| 368 | self::$FRAGMENT_TYPES[] = $className; |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | /** @inheritDoc */ |
| 373 | protected function toJsonArray(): array { |
| 374 | return $this->srcOffsets === null ? [] : [ |
| 375 | 'dsr' => $this->srcOffsets |
| 376 | ]; |
| 377 | } |
| 378 | |
| 379 | /** |
| 380 | * @inheritDoc |
| 381 | * @throws JsonException |
| 382 | */ |
| 383 | public static function newFromJsonArray( array $json ): PFragment { |
| 384 | foreach ( self::$FRAGMENT_TYPES as $c ) { |
| 385 | if ( isset( $json[$c::TYPE_HINT] ) ) { |
| 386 | return $c::newFromJsonArray( $json ); |
| 387 | } |
| 388 | } |
| 389 | throw new JsonException( "unknown fragment type" ); |
| 390 | } |
| 391 | |
| 392 | /** @inheritDoc */ |
| 393 | public static function jsonClassHintFor( string $keyName ) { |
| 394 | if ( $keyName === 'dsr' ) { |
| 395 | return DomSourceRange::hint(); |
| 396 | } |
| 397 | foreach ( self::$FRAGMENT_TYPES as $c ) { |
| 398 | if ( $keyName === $c::TYPE_HINT ) { |
| 399 | return $c::jsonClassHintFor( $keyName ); |
| 400 | } |
| 401 | } |
| 402 | return null; |
| 403 | } |
| 404 | |
| 405 | public static function hint(): Hint { |
| 406 | return Hint::build( self::class, Hint::INHERITED ); |
| 407 | } |
| 408 | } |