Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
47.80% |
76 / 159 |
|
37.50% |
6 / 16 |
CRAP | |
0.00% |
0 / 1 |
| DomPageBundle | |
47.80% |
76 / 159 |
|
37.50% |
6 / 16 |
113.90 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
2 | |||
| newEmpty | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
2 | |||
| fromHtmlPageBundle | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
1 | |||
| toDom | |
63.64% |
14 / 22 |
|
0.00% |
0 / 1 |
4.77 | |||
| toSingleDocument | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| fromSingleDocument | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| fromLoadedDocument | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
2 | |||
| isSingleDocument | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| toSingleDocumentHtml | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| toInlineAttributeDocument | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
| toInlineAttributeHtml | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
3.07 | |||
| encodeForHeadElement | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| decodeFromHeadElement | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
12 | |||
| headElementHint | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| toJsonArray | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| newFromJsonArray | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | declare( strict_types = 1 ); |
| 3 | |
| 4 | namespace Wikimedia\Parsoid\Core; |
| 5 | |
| 6 | use Wikimedia\Assert\Assert; |
| 7 | use Wikimedia\JsonCodec\Hint; |
| 8 | use Wikimedia\JsonCodec\JsonCodec; |
| 9 | use Wikimedia\Parsoid\Config\SiteConfig; |
| 10 | use Wikimedia\Parsoid\DOM\Document; |
| 11 | use Wikimedia\Parsoid\DOM\DocumentFragment; |
| 12 | use Wikimedia\Parsoid\Mocks\MockSiteConfig; |
| 13 | use Wikimedia\Parsoid\Utils\DOMDataUtils; |
| 14 | use Wikimedia\Parsoid\Utils\DOMUtils; |
| 15 | use Wikimedia\Parsoid\Utils\PHPUtils; |
| 16 | use Wikimedia\Parsoid\Wt2Html\XHtmlSerializer; |
| 17 | |
| 18 | /** |
| 19 | * A page bundle stores an HTML DOM with separated data-parsoid and |
| 20 | * data-mw content. The data-parsoid and data-mw content is indexed |
| 21 | * by the id attributes on individual nodes. This content needs to |
| 22 | * be loaded before the data-parsoid and/or data-mw information can be |
| 23 | * used. |
| 24 | * |
| 25 | * Note that the parsoid/mw properties of the page bundle are in "serialized |
| 26 | * array" form; that is, they are flat arrays appropriate for json-encoding |
| 27 | * and do not contain DataParsoid or DataMw objects. |
| 28 | * |
| 29 | * See HtmlPageBundle for a similar structure used where the HTML DOM has been |
| 30 | * serialized into a string. |
| 31 | */ |
| 32 | class DomPageBundle extends BasePageBundle { |
| 33 | private bool $invalid = false; |
| 34 | |
| 35 | public function __construct( |
| 36 | /** The document, as a DOM. */ |
| 37 | public Document $doc, |
| 38 | ?array $parsoid = null, ?array $mw = null, |
| 39 | ?array $counters = null, |
| 40 | ?string $version = null, ?array $headers = null, |
| 41 | ?string $contentmodel = null, |
| 42 | /** @var array<string,DocumentFragment> Additional named DocumentFragments. */ |
| 43 | public array $fragments = [], |
| 44 | ) { |
| 45 | parent::__construct( |
| 46 | parsoid: $parsoid, |
| 47 | mw: $mw, |
| 48 | counters: $counters, |
| 49 | version: $version, |
| 50 | headers: $headers, |
| 51 | contentmodel: $contentmodel, |
| 52 | ); |
| 53 | Assert::invariant( |
| 54 | !self::isSingleDocument( $doc ), |
| 55 | 'single document should be unpacked before DomPageBundle created' |
| 56 | ); |
| 57 | } |
| 58 | |
| 59 | public static function newEmpty( |
| 60 | Document $doc, |
| 61 | ?string $version = null, |
| 62 | ?array $headers = null, |
| 63 | ?string $contentmodel = null, |
| 64 | ): self { |
| 65 | return new DomPageBundle( |
| 66 | $doc, |
| 67 | [ |
| 68 | 'ids' => [], |
| 69 | ], |
| 70 | [ |
| 71 | 'ids' => [], |
| 72 | ], |
| 73 | [ |
| 74 | 'nodedata' => -1, |
| 75 | 'annotation' => -1, |
| 76 | 'transclusion' => -1, |
| 77 | ], |
| 78 | $version, |
| 79 | $headers, |
| 80 | $contentmodel |
| 81 | ); |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * Create a DomPageBundle from a HtmlPageBundle. |
| 86 | * |
| 87 | * This simply parses the HTML string from the HtmlPageBundle, preserving |
| 88 | * the metadata. |
| 89 | */ |
| 90 | public static function fromHtmlPageBundle( HtmlPageBundle $pb ): DomPageBundle { |
| 91 | $doc = DOMUtils::parseHTML( $pb->html ); |
| 92 | $fragments = array_map( |
| 93 | static fn ( $html )=>DOMUtils::parseHTMLToFragment( $doc, $html ), |
| 94 | $pb->fragments |
| 95 | ); |
| 96 | return new DomPageBundle( |
| 97 | $doc, |
| 98 | $pb->parsoid, |
| 99 | $pb->mw, |
| 100 | $pb->counters, |
| 101 | $pb->version, |
| 102 | $pb->headers, |
| 103 | $pb->contentmodel, |
| 104 | $fragments, |
| 105 | ); |
| 106 | } |
| 107 | |
| 108 | /** |
| 109 | * Return a DOM from the contents of this page bundle. |
| 110 | * |
| 111 | * @note Although technically the Document and DocumentFragments |
| 112 | * held by the DomPageBundle are the same as the Document and |
| 113 | * DocumentFragments returned from this method, the former are not |
| 114 | * directly usable (parsoid/mw attributes are not loaded or present |
| 115 | * in inline attributes) while the latter are. It is recommended |
| 116 | * that you treat the Document/DocumentFragment held by the DomPageBundle |
| 117 | * and the Document/DocumentFragment returned by this method as separate |
| 118 | * objects and consider the DomPageBundle "used up" and invalid once |
| 119 | * ::toDom() is called. |
| 120 | * |
| 121 | * @param bool $load |
| 122 | * If `$load` is true (the default), the returned DOM will be prepared |
| 123 | * and loaded using `$options`. |
| 124 | * |
| 125 | * If `$load` is false, any data-parsoid or data-mw information from this |
| 126 | * page bundle will be converted to inline attributes in the DOM. This |
| 127 | * process is less efficient than preparing and loading the document |
| 128 | * directly from the DOM and should be avoided if possible. |
| 129 | * @param ?array $options Additional options to |
| 130 | * DOMDataUtils::prepareAndLoadDoc, used when $load is true. |
| 131 | * @param ?array<string,DocumentFragment> &$fragments Additional fragments |
| 132 | * present in the page bundle, which will also be loaded as necessary. |
| 133 | * This is an output parameter. |
| 134 | * @param ?SiteConfig $siteConfig used to initialize the document codec |
| 135 | */ |
| 136 | public function toDom( |
| 137 | bool $load = true, ?array $options = null, |
| 138 | ?array &$fragments = null, |
| 139 | ?SiteConfig $siteConfig = null |
| 140 | ): Document { |
| 141 | Assert::invariant( !$this->invalid, "invalidated" ); |
| 142 | $doc = $this->doc; |
| 143 | $options ??= []; |
| 144 | if ( $load ) { |
| 145 | $fragments = []; |
| 146 | foreach ( $this->fragments as $name => $f ) { |
| 147 | $fragments[$name] = $f; |
| 148 | } |
| 149 | $options['loadFromPageBundle'] = $this; |
| 150 | $options['fragments'] = $this->fragments; |
| 151 | $siteConfig ??= $options['siteConfig'] ?? null; |
| 152 | if ( $siteConfig === null ) { |
| 153 | PHPUtils::deprecated( __METHOD__ . ' without SiteConfig', '0.24' ); |
| 154 | $siteConfig = new MockSiteConfig( [] ); |
| 155 | } |
| 156 | DOMDataUtils::prepareAndLoadDoc( $doc, $siteConfig, $options ); |
| 157 | } else { |
| 158 | PHPUtils::deprecated( __METHOD__ . ' with $load=false', '0.23' ); |
| 159 | $doc = $this->toInlineAttributeDocument( |
| 160 | siteConfig: $siteConfig ?? new MockSiteConfig( [] ), |
| 161 | options: $options, |
| 162 | fragments: $fragments |
| 163 | ); |
| 164 | } |
| 165 | $this->invalid = true; |
| 166 | return $doc; |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * Create a "PageBundle as single Document" by embedding page bundle |
| 171 | * information into a <script> element in the <head> of the DOM. |
| 172 | * |
| 173 | * @see ::fromSingleDocument() |
| 174 | */ |
| 175 | public function toSingleDocument(): Document { |
| 176 | Assert::invariant( !$this->invalid, "invalidated" ); |
| 177 | $script = DOMUtils::appendToHead( $this->doc, 'script', [ |
| 178 | 'id' => 'mw-pagebundle', |
| 179 | 'type' => 'application/x-mw-pagebundle', |
| 180 | ] ); |
| 181 | $script->appendChild( $this->doc->createTextNode( $this->encodeForHeadElement() ) ); |
| 182 | $doc = $this->doc; |
| 183 | // Invalidate this DomPageBundle to prevent us from using it again. |
| 184 | $this->invalid = true; |
| 185 | return $doc; |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * Return a DomPageBundle from a "PageBundle as single Document" |
| 190 | * representation, where some page bundle information has been embedded |
| 191 | * as a <script> element into the <head> of the DOM. |
| 192 | * |
| 193 | * @see ::toSingleDocument() |
| 194 | * |
| 195 | * @param Document $doc doc |
| 196 | * @param array $options Optional content version/headers/contentmodel |
| 197 | * @return DomPageBundle |
| 198 | */ |
| 199 | public static function fromSingleDocument( Document $doc, array $options = [] ): DomPageBundle { |
| 200 | $dpScriptElt = DOMCompat::getElementById( $doc, 'mw-pagebundle' ); |
| 201 | Assert::invariant( $dpScriptElt !== null, "no page bundle found" ); |
| 202 | $dpScriptElt->parentNode->removeChild( $dpScriptElt ); |
| 203 | return self::decodeFromHeadElement( $doc, $dpScriptElt->textContent, $options ); |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Create a new DomPageBundle from a "prepared and loaded" document. |
| 208 | * |
| 209 | * If a `pageBundle` key is present in the options, the |
| 210 | * version/headers/contentmodel will be initialized from that |
| 211 | * page bundle. |
| 212 | * |
| 213 | * @param Document $doc Should be "prepared and loaded" |
| 214 | * @param SiteConfig $siteConfig |
| 215 | * @param array $options store options |
| 216 | * @param array<string,DocumentFragment> $fragments |
| 217 | * @return DomPageBundle |
| 218 | */ |
| 219 | public static function fromLoadedDocument( |
| 220 | Document $doc, SiteConfig $siteConfig, |
| 221 | array $options = [], array $fragments = [], |
| 222 | ): DomPageBundle { |
| 223 | $metadata = $options['pageBundle'] ?? null; |
| 224 | $dpb = self::newEmpty( |
| 225 | $doc, |
| 226 | $metadata->version ?? $options['contentversion'] ?? null, |
| 227 | $metadata->headers ?? $options['headers'] ?? null, |
| 228 | $metadata->contentmodel ?? $options['contentmodel'] ?? null, |
| 229 | ); |
| 230 | // FIXME: Should we init $dpb->counters here using databag? |
| 231 | $options = [ |
| 232 | 'storeInPageBundle' => $dpb, |
| 233 | 'outputContentVersion' => $dpb->version, |
| 234 | 'idIndex' => DOMDataUtils::usedIdIndex( $siteConfig, $doc, $fragments ), |
| 235 | ] + $options; |
| 236 | DOMDataUtils::visitAndStoreDataAttribs( |
| 237 | DOMCompat::getBody( $doc ), $options |
| 238 | ); |
| 239 | foreach ( $fragments as $name => $f ) { |
| 240 | DOMDataUtils::visitAndStoreDataAttribs( |
| 241 | $f, $options |
| 242 | ); |
| 243 | $dpb->fragments[$name] = $f; |
| 244 | } |
| 245 | return $dpb; |
| 246 | } |
| 247 | |
| 248 | /** |
| 249 | * Return true iff the given Document has page bundle information embedded |
| 250 | * as a <script id="mw-pagebundle"> element in its <head>. |
| 251 | */ |
| 252 | public static function isSingleDocument( Document $doc ): bool { |
| 253 | return DOMCompat::getElementById( $doc, 'mw-pagebundle' ) !== null; |
| 254 | } |
| 255 | |
| 256 | /** |
| 257 | * Convert this DomPageBundle to "single document" form, where page bundle |
| 258 | * information is embedded in the <head> of the document. |
| 259 | * @param array $options XHtmlSerializer options |
| 260 | * @return string an HTML string |
| 261 | */ |
| 262 | public function toSingleDocumentHtml( array $options = [] ): string { |
| 263 | Assert::invariant( !$this->invalid, "invalidated" ); |
| 264 | $doc = $this->toSingleDocument(); |
| 265 | return XHtmlSerializer::serialize( $doc, $options )['html']; |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Convert this DomPageBundle to "inline attribute" form, where page bundle |
| 270 | * information is represented as inline JSON-valued attributes. |
| 271 | * @param SiteConfig $siteConfig |
| 272 | * @param array $options XHtmlSerializer options |
| 273 | * @param array<string,DocumentFragment>|null &$fragments Additional fragments from the |
| 274 | * page bundle which will also be converted to "inline attribute" form. |
| 275 | * This is an output parameter. |
| 276 | * @return Document a standalone document with page bundle information |
| 277 | * represented as inline JSON-valued attributes. |
| 278 | */ |
| 279 | public function toInlineAttributeDocument( |
| 280 | SiteConfig $siteConfig, |
| 281 | array $options = [], |
| 282 | ?array &$fragments = null, |
| 283 | ): Document { |
| 284 | Assert::invariant( !$this->invalid, "invalidated" ); |
| 285 | $doc = $this->toDom( siteConfig: $siteConfig, fragments: $fragments ); |
| 286 | $options = [ |
| 287 | 'idIndex' => DOMDataUtils::usedIdIndex( $siteConfig, $doc, $fragments ), |
| 288 | 'fragments' => array_values( $fragments ) |
| 289 | ] + $options; |
| 290 | DOMDataUtils::storeAndUnprepareDoc( $doc, $options ); |
| 291 | return $doc; |
| 292 | } |
| 293 | |
| 294 | /** |
| 295 | * Convert this DomPageBundle to "inline attribute" form, where page bundle |
| 296 | * information is represented as inline JSON-valued attributes. |
| 297 | * @param SiteConfig $siteConfig |
| 298 | * @param array $options XHtmlSerializer options |
| 299 | * @param array<string,string>|null &$fragments Additional fragments from the |
| 300 | * page bundle which will also be serialized to HTML strings. |
| 301 | * This is an output parameter. |
| 302 | * @return string an HTML string |
| 303 | */ |
| 304 | public function toInlineAttributeHtml( |
| 305 | SiteConfig $siteConfig, |
| 306 | array $options = [], |
| 307 | ?array &$fragments = null, |
| 308 | ): string { |
| 309 | $doc = $this->toInlineAttributeDocument( |
| 310 | siteConfig: $siteConfig, options: $options, fragments: $fragments |
| 311 | ); |
| 312 | foreach ( $fragments as $name => $f ) { |
| 313 | // @phan-suppress-next-line PhanTypeMismatchArgument phan confused |
| 314 | $fragments[$name] = XHtmlSerializer::serialize( $f, $options )['html']; |
| 315 | } |
| 316 | if ( $options['body_only'] ?? false ) { |
| 317 | $node = DOMCompat::getBody( $doc ); |
| 318 | $options['innerXML'] = true; |
| 319 | } else { |
| 320 | $node = $doc; |
| 321 | } |
| 322 | return XHtmlSerializer::serialize( $node, $options )['html']; |
| 323 | } |
| 324 | |
| 325 | /** |
| 326 | * Encode some page bundle properties for emitting as a <script> element |
| 327 | * in the <head> of a document. |
| 328 | */ |
| 329 | private function encodeForHeadElement(): string { |
| 330 | // Note that $this->parsoid and $this->mw are already serialized arrays |
| 331 | // so a naive jsonEncode is sufficient. We use a JsonCodec to ensure |
| 332 | // that objects stay objects and arrays stay arrays, though. |
| 333 | $json = [ 'parsoid' => $this->parsoid ?? [], 'mw' => $this->mw ?? [], 'counters' => $this->counters ?? [] ]; |
| 334 | if ( $this->fragments ) { |
| 335 | // Preserve fragments in the <head> |
| 336 | $json['fragments'] = array_map( |
| 337 | static fn ( $f ) => XHtmlSerializer::serialize( $f, [] )['html'], |
| 338 | $this->fragments |
| 339 | ); |
| 340 | } |
| 341 | $codec = new JsonCodec(); |
| 342 | return $codec->toJsonString( $json, self::headElementHint() ); |
| 343 | } |
| 344 | |
| 345 | /** |
| 346 | * Decode some page bundle properties from the contents of the <script> |
| 347 | * element embedded in a document. |
| 348 | */ |
| 349 | private static function decodeFromHeadElement( Document $doc, string $s, array $options = [] ): DomPageBundle { |
| 350 | // Note that only 'parsoid' and 'mw' are encoded, so these will be |
| 351 | // the only fields set in the decoded DomPageBundle |
| 352 | // Even though 'parsoid' and 'mw' are encoded, use a JsonCodec so |
| 353 | // that objects stay objects and arrays stay arrays. |
| 354 | $codec = new JsonCodec(); |
| 355 | $decoded = $codec->newFromJsonString( $s, self::headElementHint() ); |
| 356 | $fragments = array_map( |
| 357 | static fn ( $html ) => DOMUtils::parseHTMLToFragment( $doc, $html ), |
| 358 | $decoded['fragments'] ?? [] |
| 359 | ); |
| 360 | // Backward compatibility with Parsoid < 0.23 |
| 361 | if ( !isset( $decoded['counters'] ) ) { |
| 362 | $decoded['counters'] = [ |
| 363 | 'nodedata' => $decoded['parsoid']['counter'] ?? -1, |
| 364 | 'annotation' => -1, |
| 365 | 'transclusion' => -1, |
| 366 | ]; |
| 367 | } |
| 368 | if ( isset( $decoded['parsoid']['counter'] ) ) { |
| 369 | unset( $decoded['parsoid']['counter'] ); |
| 370 | } |
| 371 | return new DomPageBundle( |
| 372 | $doc, |
| 373 | $decoded['parsoid'] ?? null, |
| 374 | $decoded['mw'] ?? null, |
| 375 | $decoded['counters'] ?? null, |
| 376 | $options['contentversion'] ?? null, |
| 377 | $options['headers'] ?? null, |
| 378 | $options['contentmodel'] ?? null, |
| 379 | $fragments, |
| 380 | ); |
| 381 | } |
| 382 | |
| 383 | private static function headElementHint(): Hint { |
| 384 | // @phan-suppress-next-line PhanUndeclaredClassReference array |
| 385 | return Hint::build( 'array', Hint::LIST, Hint::LIST, Hint::LIST ); |
| 386 | } |
| 387 | |
| 388 | // JsonCodecable ------------- |
| 389 | |
| 390 | /** @inheritDoc */ |
| 391 | public function toJsonArray(): array { |
| 392 | Assert::invariant( !$this->invalid, "invalidated" ); |
| 393 | return HtmlPageBundle::fromDomPageBundle( $this )->toJsonArray(); |
| 394 | } |
| 395 | |
| 396 | /** @inheritDoc */ |
| 397 | public static function newFromJsonArray( array $json ): DomPageBundle { |
| 398 | $pb = HtmlPageBundle::newFromJsonArray( $json ); |
| 399 | return self::fromHtmlPageBundle( $pb ); |
| 400 | } |
| 401 | } |