| 1 |
<?php |
| 2 |
/** |
| 3 |
* Resource Hints processor — pure HTML transformer for resource hints. |
| 4 |
* |
| 5 |
* Given a fully-rendered page and the Preload module's options, it: |
| 6 |
* 1. Finds the first N above-the-fold <img> (the LCP candidate) and emits |
| 7 |
* a <link rel="preload" as="image" fetchpriority="high"> for each in |
| 8 |
* the <head>, carrying srcset/sizes as imagesrcset/imagesizes so the |
| 9 |
* browser can pick the right candidate — then adds fetchpriority="high" |
| 10 |
* to the <img> itself so it beats any loading="lazy" the theme set. |
| 11 |
* 2. Emits <link rel="preconnect"> for detected web-font hosts |
| 12 |
* (fonts.googleapis.com + fonts.gstatic.com) and any user-supplied |
| 13 |
* hosts, deduped. |
| 14 |
* |
| 15 |
* Kept as a pure static so the test suite can drive it without booting the |
| 16 |
* module or WordPress hooks (mirrors Lazy_Loader::process_html). All output |
| 17 |
* is escaped at build time; callers echo the result verbatim into the body. |
| 18 |
* |
| 19 |
* @package XSpeed |
| 20 |
*/ |
| 21 |
|
| 22 |
declare(strict_types=1); |
| 23 |
|
| 24 |
namespace XSpeed; |
| 25 |
|
| 26 |
defined( 'ABSPATH' ) || exit; |
| 27 |
|
| 28 |
final class Resource_Hints_Processor { |
| 29 |
|
| 30 |
/** |
| 31 |
* Transform the page HTML, injecting preload + preconnect hints. |
| 32 |
* |
| 33 |
* @param string $html Fully-rendered page HTML. |
| 34 |
* @param array<string,mixed> $opts Preload module settings. |
| 35 |
* @return string Rewritten HTML (unchanged when disabled or no match). |
| 36 |
*/ |
| 37 |
public static function process( string $html, array $opts ): string { |
| 38 |
if ( empty( $opts['enabled'] ) ) { |
| 39 |
return $html; |
| 40 |
} |
| 41 |
|
| 42 |
// Only touch real HTML documents. A JSON/XML/feed body that happens |
| 43 |
// to reach here should pass through untouched. |
| 44 |
if ( false === stripos( $html, '<html' ) && false === stripos( $html, '<body' ) && false === stripos( $html, '<head' ) ) { |
| 45 |
return $html; |
| 46 |
} |
| 47 |
|
| 48 |
$hints = ''; |
| 49 |
|
| 50 |
if ( ! empty( $opts['preconnect'] ) || ! empty( $opts['preconnect_hosts'] ) ) { |
| 51 |
$hints .= self::build_preconnect( $html, (array) ( $opts['preconnect_hosts'] ?? array() ), ! empty( $opts['preconnect'] ) ); |
| 52 |
} |
| 53 |
|
| 54 |
if ( ! empty( $opts['lcp_preload'] ) ) { |
| 55 |
$count = max( 0, (int) ( $opts['lcp_image_count'] ?? 1 ) ); |
| 56 |
$exclusions = array_filter( array_map( 'strval', (array) ( $opts['lcp_exclusions'] ?? array() ) ) ); |
| 57 |
[ $html, $preload ] = self::build_lcp_preload( $html, $count, $exclusions ); |
| 58 |
$hints .= $preload; |
| 59 |
} |
| 60 |
|
| 61 |
// Full-page eager promotion for Lazy-excluded heroes (FBS-83553 H2). The |
| 62 |
// Lazy module only filters the_content/thumbnail/avatar/widget, so a |
| 63 |
// theme/builder hero printed OUTSIDE those keeps WP core's |
| 64 |
// loading="lazy". This pass runs over the whole document, so it can reach |
| 65 |
// those heroes: for any <img> matching an exclusion pattern, strip lazy + |
| 66 |
// set fetchpriority=high. NOTE: this mutates $html even when no <head> |
| 67 |
// hints are emitted, so it must apply before the empty-$hints early-out. |
| 68 |
$eager = array_filter( array_map( 'strval', (array) ( $opts['eager_excluded_images'] ?? array() ) ) ); |
| 69 |
if ( ! empty( $eager ) ) { |
| 70 |
$html = self::promote_excluded_images( $html, $eager ); |
| 71 |
} |
| 72 |
|
| 73 |
if ( '' === $hints ) { |
| 74 |
return $html; |
| 75 |
} |
| 76 |
|
| 77 |
return self::inject_into_head( $html, $hints ); |
| 78 |
} |
| 79 |
|
| 80 |
/** |
| 81 |
* Strip core `loading="lazy"` and add `fetchpriority="high"` + |
| 82 |
* `decoding="async"` on every <img> whose tag matches one of the given |
| 83 |
* exclusion substrings. Mirrors what Lazy_Loader does for an excluded image |
| 84 |
* inside the_content, but page-wide so heroes outside it are covered too. |
| 85 |
* (FBS-83553 H2) |
| 86 |
* |
| 87 |
* @param string $html Full page HTML. |
| 88 |
* @param string[] $exclusions Substring patterns identifying above-the-fold heroes. |
| 89 |
*/ |
| 90 |
private static function promote_excluded_images( string $html, array $exclusions ): string { |
| 91 |
return (string) preg_replace_callback( |
| 92 |
'#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', |
| 93 |
static function ( array $m ) use ( $exclusions ) { |
| 94 |
$tag = $m[0]; |
| 95 |
foreach ( $exclusions as $needle ) { |
| 96 |
if ( '' !== $needle && false !== stripos( $tag, $needle ) ) { |
| 97 |
$tag = (string) preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag ); |
| 98 |
$tag = self::set_fetchpriority( $tag ); |
| 99 |
if ( ! preg_match( '#\bdecoding=#i', $tag ) ) { |
| 100 |
$tag = (string) preg_replace( '#<img\b#i', '<img decoding="async"', $tag, 1 ); |
| 101 |
} |
| 102 |
return $tag; |
| 103 |
} |
| 104 |
} |
| 105 |
return $tag; |
| 106 |
}, |
| 107 |
$html |
| 108 |
); |
| 109 |
} |
| 110 |
|
| 111 |
/** |
| 112 |
* Build preconnect <link>s for detected font hosts + user hosts. |
| 113 |
* Deduped and idempotent (skips hosts already preconnected in $html). |
| 114 |
* |
| 115 |
* @param string $html Page HTML (scanned for font stylesheets). |
| 116 |
* @param string[] $user_hosts Extra hosts to always preconnect. |
| 117 |
* @param bool $auto_fonts Whether to auto-add font hosts. |
| 118 |
* @return string preconnect <link> markup. |
| 119 |
*/ |
| 120 |
private static function build_preconnect( string $html, array $user_hosts, bool $auto_fonts ): string { |
| 121 |
$hosts = array(); |
| 122 |
|
| 123 |
if ( $auto_fonts && false !== stripos( $html, 'fonts.googleapis.com' ) ) { |
| 124 |
// The stylesheet is on googleapis; the font files stream from |
| 125 |
// gstatic — preconnect both, gstatic needs crossorigin. |
| 126 |
$hosts['https://fonts.googleapis.com'] = false; |
| 127 |
$hosts['https://fonts.gstatic.com'] = true; |
| 128 |
} |
| 129 |
|
| 130 |
foreach ( $user_hosts as $host ) { |
| 131 |
$host = trim( (string) $host ); |
| 132 |
if ( '' === $host ) { |
| 133 |
continue; |
| 134 |
} |
| 135 |
// Cross-origin hosts get crossorigin by default; harmless for |
| 136 |
// same-scheme document hosts and required for fonts/fetch. |
| 137 |
$hosts[ untrailingslashit( $host ) ] = true; |
| 138 |
} |
| 139 |
|
| 140 |
$out = ''; |
| 141 |
foreach ( $hosts as $host => $crossorigin ) { |
| 142 |
// Idempotency: skip a host already preconnected in the document. |
| 143 |
if ( preg_match( '#rel=["\']preconnect["\'][^>]*' . preg_quote( $host, '#' ) . '#i', $html ) |
| 144 |
|| preg_match( '#' . preg_quote( $host, '#' ) . '[^>]*rel=["\']preconnect["\']#i', $html ) ) { |
| 145 |
continue; |
| 146 |
} |
| 147 |
$out .= sprintf( |
| 148 |
'<link rel="preconnect" href="%s"%s>' . "\n", |
| 149 |
esc_url( $host ), |
| 150 |
$crossorigin ? ' crossorigin' : '' |
| 151 |
); |
| 152 |
} |
| 153 |
|
| 154 |
return $out; |
| 155 |
} |
| 156 |
|
| 157 |
/** |
| 158 |
* Find the first $count eligible <img> tags, add fetchpriority="high" |
| 159 |
* to each, and return the matching <link rel=preload as=image> markup. |
| 160 |
* |
| 161 |
* @param string $html Page HTML. |
| 162 |
* @param int $count How many top images to preload. |
| 163 |
* @param string[] $exclusions Substring patterns that exempt an <img>. |
| 164 |
* @return array{0:string,1:string} [rewritten html, preload markup] |
| 165 |
*/ |
| 166 |
private static function build_lcp_preload( string $html, int $count, array $exclusions ): array { |
| 167 |
if ( $count < 1 ) { |
| 168 |
return array( $html, '' ); |
| 169 |
} |
| 170 |
|
| 171 |
$preload = ''; |
| 172 |
$done = 0; |
| 173 |
|
| 174 |
// Snapshot of already-present preload markup, for idempotency: a second |
| 175 |
// pass (e.g. cache-off ob_start over an already-processed body) must not |
| 176 |
// re-emit a <link> for an image we preloaded before. |
| 177 |
$existing = $html; |
| 178 |
|
| 179 |
// Walk <img> tags in document order. preg_replace_callback lets us |
| 180 |
// rewrite the tag (add fetchpriority) and harvest the src in one pass. |
| 181 |
$html = preg_replace_callback( |
| 182 |
'#<img\b[^>]*>#i', |
| 183 |
static function ( array $m ) use ( &$done, &$preload, $count, $exclusions, $existing ) { |
| 184 |
$tag = $m[0]; |
| 185 |
if ( $done >= $count ) { |
| 186 |
return $tag; |
| 187 |
} |
| 188 |
|
| 189 |
// Skip anything the user excluded. |
| 190 |
foreach ( $exclusions as $needle ) { |
| 191 |
if ( '' !== $needle && false !== stripos( $tag, $needle ) ) { |
| 192 |
return $tag; |
| 193 |
} |
| 194 |
} |
| 195 |
|
| 196 |
// Resolve the EFFECTIVE image URL. Page builders + JS lazy |
| 197 |
// loaders park a placeholder (a data: URI or a 1px spacer) in |
| 198 |
// `src` and the real URL in `data-src`, so the hero the browser |
| 199 |
// actually paints is behind data-src. Reading `src` alone made |
| 200 |
// the LCP picker skip the real hero and fasten onto a later plain |
| 201 |
// <img> decoy. Prefer data-src when src is a placeholder. |
| 202 |
// (FBS-83553 H1) |
| 203 |
[ $src, $srcset, $sizes ] = self::effective_image_src( $tag ); |
| 204 |
if ( '' === $src ) { |
| 205 |
return $tag; // no real URL (pure data-URI spacer, no data-src). |
| 206 |
} |
| 207 |
|
| 208 |
// Size gate: never spend the LCP preload budget on an image that |
| 209 |
// is obviously not the hero — a logo/icon/avatar. When explicit |
| 210 |
// width & height are on the tag and it's small in BOTH dimensions, |
| 211 |
// skip it and keep looking. Missing dimensions → don't guess, let |
| 212 |
// it through. (FBS-83553 H1 "logo before hero".) |
| 213 |
if ( self::looks_too_small( $tag ) ) { |
| 214 |
return $tag; |
| 215 |
} |
| 216 |
|
| 217 |
// Idempotency: if this src is already the target of a |
| 218 |
// rel="preload" as="image" link, count it as done (so the |
| 219 |
// budget is respected) but don't emit a duplicate <link>. |
| 220 |
$already = (bool) preg_match( |
| 221 |
'#rel=["\']preload["\'][^>]*as=["\']image["\'][^>]*' . preg_quote( $src, '#' ) . '#i', |
| 222 |
$existing |
| 223 |
); |
| 224 |
if ( ! $already ) { |
| 225 |
$preload .= self::preload_link( $src, $srcset, $sizes ); |
| 226 |
} |
| 227 |
$done++; |
| 228 |
|
| 229 |
// Add fetchpriority="high" AND remove any loading="lazy" the |
| 230 |
// theme / WP core left on the LCP image. fetchpriority="high" |
| 231 |
// with loading="lazy" is contradictory — the browser can still |
| 232 |
// defer a lazy image, so preloading it while it stays lazy wins |
| 233 |
// nothing. Stripping lazy is what actually lets the preload land. |
| 234 |
return self::promote_lcp_img( $tag ); |
| 235 |
}, |
| 236 |
$html |
| 237 |
); |
| 238 |
|
| 239 |
return array( (string) $html, $preload ); |
| 240 |
} |
| 241 |
|
| 242 |
/** |
| 243 |
* Assemble one <link rel="preload" as="image" fetchpriority="high">. |
| 244 |
* |
| 245 |
* The href/srcset run through the `xspeed_lcp_preload_url` / |
| 246 |
* `xspeed_lcp_preload_srcset` filters first. This is the coordination point |
| 247 |
* with format-negotiating layers (Pro's Images module wraps the LCP <img> in |
| 248 |
* a <picture> with a WebP/AVIF <source>, so the browser paints e.g. |
| 249 |
* hero.png.webp, NOT the hero.png this preload would otherwise point at — |
| 250 |
* making the high-priority preload a wasted download while the real LCP |
| 251 |
* resource goes un-preloaded). By filtering the URL, a webp/avif layer can |
| 252 |
* redirect the preload to the format it will actually serve, WITHOUT Free |
| 253 |
* knowing that layer exists. (FBS-83553 H3) |
| 254 |
*/ |
| 255 |
private static function preload_link( string $src, string $srcset, string $sizes ): string { |
| 256 |
// Resolve the `type` from the ORIGINAL image URL (before rewriting), so a |
| 257 |
// negotiating layer can key off the source .jpg/.png — after rewriting, |
| 258 |
// the URL is already a .webp and the derivation would no-op. |
| 259 |
$original = $src; |
| 260 |
/** |
| 261 |
* Filter an explicit `type` for the preload link (e.g. "image/webp"). |
| 262 |
* Empty = omit. A typed image preload is only fetched by browsers that |
| 263 |
* accept that type, so pairing a webp href with type="image/webp" is safe |
| 264 |
* even though the markup is baked into a shared cache file. |
| 265 |
* |
| 266 |
* @param string $type Defaults to '' (no type attribute). |
| 267 |
* @param string $src The ORIGINAL (pre-rewrite) preload URL. |
| 268 |
*/ |
| 269 |
$type = (string) apply_filters( 'xspeed_lcp_preload_type', '', $original ); |
| 270 |
/** |
| 271 |
* Filter the LCP preload href. Return a modern-format sibling (webp/avif) |
| 272 |
* when one will actually be served for this image. |
| 273 |
* |
| 274 |
* @param string $src The original image URL chosen for preload. |
| 275 |
*/ |
| 276 |
$src = (string) apply_filters( 'xspeed_lcp_preload_url', $src ); |
| 277 |
if ( '' !== $srcset ) { |
| 278 |
/** @param string $srcset The original srcset chosen for preload. */ |
| 279 |
$srcset = (string) apply_filters( 'xspeed_lcp_preload_srcset', $srcset ); |
| 280 |
} |
| 281 |
|
| 282 |
$attrs = sprintf( 'href="%s"', esc_url( $src ) ); |
| 283 |
|
| 284 |
if ( '' !== $srcset ) { |
| 285 |
// Preserve the responsive candidate set so the browser preloads |
| 286 |
// the same file it would have chosen from the <img>. |
| 287 |
$attrs .= sprintf( ' imagesrcset="%s"', esc_attr( html_entity_decode( $srcset, ENT_QUOTES ) ) ); |
| 288 |
if ( '' !== $sizes ) { |
| 289 |
$attrs .= sprintf( ' imagesizes="%s"', esc_attr( html_entity_decode( $sizes, ENT_QUOTES ) ) ); |
| 290 |
} |
| 291 |
} |
| 292 |
|
| 293 |
if ( '' !== $type ) { |
| 294 |
$attrs .= sprintf( ' type="%s"', esc_attr( $type ) ); |
| 295 |
} |
| 296 |
|
| 297 |
return sprintf( '<link rel="preload" as="image" %s fetchpriority="high">' . "\n", $attrs ); |
| 298 |
} |
| 299 |
|
| 300 |
/** |
| 301 |
* Extract a single/double-quoted attribute value from a tag. Returns '' |
| 302 |
* when the attribute is absent. |
| 303 |
*/ |
| 304 |
private static function attr( string $tag, string $name ): string { |
| 305 |
if ( preg_match( '#\b' . preg_quote( $name, '#' ) . '=(["\'])(.*?)\1#is', $tag, $m ) ) { |
| 306 |
return trim( $m[2] ); |
| 307 |
} |
| 308 |
return ''; |
| 309 |
} |
| 310 |
|
| 311 |
/** |
| 312 |
* Resolve the URL/srcset/sizes the browser will actually paint for an |
| 313 |
* <img>, seeing through JS-lazy placeholders. When `src` is a data: URI (a |
| 314 |
* builder/lazy-loader placeholder), fall back to `data-src`; likewise carry |
| 315 |
* `data-srcset`/`data-sizes` when the plain ones are absent. Returns |
| 316 |
* ['', '', ''] when there's no real raster URL to preload. (FBS-83553 H1) |
| 317 |
* |
| 318 |
* @return array{0:string,1:string,2:string} [src, srcset, sizes] |
| 319 |
*/ |
| 320 |
private static function effective_image_src( string $tag ): array { |
| 321 |
$src = self::attr( $tag, 'src' ); |
| 322 |
if ( '' === $src || 0 === stripos( $src, 'data:' ) ) { |
| 323 |
$data_src = self::attr( $tag, 'data-src' ); |
| 324 |
if ( '' !== $data_src && 0 !== stripos( $data_src, 'data:' ) ) { |
| 325 |
$src = $data_src; |
| 326 |
} |
| 327 |
} |
| 328 |
if ( '' === $src || 0 === stripos( $src, 'data:' ) ) { |
| 329 |
return array( '', '', '' ); |
| 330 |
} |
| 331 |
$srcset = self::attr( $tag, 'srcset' ); |
| 332 |
if ( '' === $srcset ) { |
| 333 |
$srcset = self::attr( $tag, 'data-srcset' ); |
| 334 |
} |
| 335 |
$sizes = self::attr( $tag, 'sizes' ); |
| 336 |
if ( '' === $sizes ) { |
| 337 |
$sizes = self::attr( $tag, 'data-sizes' ); |
| 338 |
} |
| 339 |
return array( $src, $srcset, $sizes ); |
| 340 |
} |
| 341 |
|
| 342 |
/** |
| 343 |
* At/below this (px) in BOTH width and height, an image is treated as a |
| 344 |
* logo/icon/avatar rather than an LCP hero. 200px clears real content heroes |
| 345 |
* (which are typically ≥ 400px wide) while catching site logos and avatars |
| 346 |
* — including the 150×150 logo the picker used to mistakenly preload. |
| 347 |
*/ |
| 348 |
private const MIN_LCP_DIMENSION = 200; |
| 349 |
|
| 350 |
/** |
| 351 |
* Class/role/filename markers that identify site chrome (logo, icon, |
| 352 |
* avatar, spinner, emoji) which should never be treated as the LCP hero, |
| 353 |
* regardless of declared size. |
| 354 |
*/ |
| 355 |
private const NON_HERO_MARKERS = array( 'logo', 'icon', 'avatar', 'gravatar', 'spinner', 'emoji', 'site-icon', 'custom-logo' ); |
| 356 |
|
| 357 |
/** |
| 358 |
* Is this <img> too small / too chrome-like to be the LCP hero? True when |
| 359 |
* either (a) it carries a logo/icon/avatar marker, (b) an explicit |
| 360 |
* `data-no-lcp` opt-out, or (c) BOTH width and height are present and both |
| 361 |
* are ≤ the threshold. Missing dimensions are NOT guessed — an image whose |
| 362 |
* size we can't read still competes. (FBS-83553 H1 "logo before hero".) |
| 363 |
*/ |
| 364 |
private static function looks_too_small( string $tag ): bool { |
| 365 |
if ( false !== stripos( $tag, 'data-no-lcp' ) ) { |
| 366 |
return true; |
| 367 |
} |
| 368 |
// Marker check against class / id / src (covers "custom-logo", a |
| 369 |
// "…/logo.png" filename, role="img" avatars, etc.). |
| 370 |
$haystack = strtolower( self::attr( $tag, 'class' ) . ' ' . self::attr( $tag, 'id' ) . ' ' . self::attr( $tag, 'src' ) ); |
| 371 |
foreach ( self::NON_HERO_MARKERS as $marker ) { |
| 372 |
if ( false !== strpos( $haystack, $marker ) ) { |
| 373 |
return true; |
| 374 |
} |
| 375 |
} |
| 376 |
$w = self::attr( $tag, 'width' ); |
| 377 |
$h = self::attr( $tag, 'height' ); |
| 378 |
if ( '' === $w || '' === $h || ! is_numeric( $w ) || ! is_numeric( $h ) ) { |
| 379 |
return false; // unknown size — don't guess; let it compete. |
| 380 |
} |
| 381 |
return (int) $w <= self::MIN_LCP_DIMENSION && (int) $h <= self::MIN_LCP_DIMENSION; |
| 382 |
} |
| 383 |
|
| 384 |
/** |
| 385 |
* Promote an <img> to the LCP element: force fetchpriority="high" and |
| 386 |
* strip any loading="lazy" so the browser loads it immediately. Both are |
| 387 |
* idempotent. `loading="lazy"` is REMOVED rather than flipped to "eager" |
| 388 |
* because eager is the default; a bare tag with fetchpriority="high" is |
| 389 |
* the canonical high-priority-image form. |
| 390 |
*/ |
| 391 |
private static function promote_lcp_img( string $tag ): string { |
| 392 |
$tag = self::set_fetchpriority( $tag ); |
| 393 |
// Drop loading="lazy" (WP core adds it by default). Leave other |
| 394 |
// loading values (e.g. an explicit eager) intact — only lazy hurts. |
| 395 |
$tag = preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag ); |
| 396 |
return (string) $tag; |
| 397 |
} |
| 398 |
|
| 399 |
/** |
| 400 |
* Add fetchpriority="high" to an <img> tag. Idempotent — an existing |
| 401 |
* fetchpriority value is normalised to high rather than duplicated. |
| 402 |
*/ |
| 403 |
private static function set_fetchpriority( string $tag ): string { |
| 404 |
if ( preg_match( '#\bfetchpriority=(["\']).*?\1#i', $tag ) ) { |
| 405 |
return (string) preg_replace( '#\bfetchpriority=(["\']).*?\1#i', 'fetchpriority="high"', $tag, 1 ); |
| 406 |
} |
| 407 |
// Insert right after "<img". |
| 408 |
return (string) preg_replace( '#<img\b#i', '<img fetchpriority="high"', $tag, 1 ); |
| 409 |
} |
| 410 |
|
| 411 |
/** |
| 412 |
* Inject the assembled hint markup into <head>. Prefers to land right |
| 413 |
* before the first stylesheet so the preloads are discovered before the |
| 414 |
* render-blocking CSS. Falls back to after <head>, then prepend. |
| 415 |
*/ |
| 416 |
private static function inject_into_head( string $html, string $hints ): string { |
| 417 |
// Before the first <link rel="stylesheet"> if there is one. |
| 418 |
if ( preg_match( '#<link\b[^>]*rel=["\']stylesheet["\'][^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { |
| 419 |
$pos = $m[0][1]; |
| 420 |
return substr( $html, 0, $pos ) . $hints . substr( $html, $pos ); |
| 421 |
} |
| 422 |
// Otherwise right after the opening <head ...>. |
| 423 |
if ( preg_match( '#<head\b[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { |
| 424 |
$pos = $m[0][1] + strlen( $m[0][0] ); |
| 425 |
return substr( $html, 0, $pos ) . "\n" . $hints . substr( $html, $pos ); |
| 426 |
} |
| 427 |
// No head at all — prepend (degenerate documents). |
| 428 |
return $hints . $html; |
| 429 |
} |
| 430 |
} |
| 431 |
|