$externals * @return array> */ public static function normalize(array $externals): array { $normalized = []; $seen = []; foreach ($externals as $external) { if (! is_array($external)) { continue; } $item = self::normalizeItem($external); if ($item === null) { continue; } $key = $item['type'].'|'.$item['url']; if (isset($seen[$key])) { continue; } $seen[$key] = true; $normalized[] = $item; } return $normalized; } /** * @param array> $externals * @return array> */ public static function resourceHints(array $externals): array { $hints = []; $seen = []; foreach ($externals as $external) { if (($external['type'] ?? null) === 'preconnect') { self::appendHint($hints, $seen, 'preconnect', $external['url'], $external['crossorigin'] ?? null); } if (($external['type'] ?? null) === 'dns-prefetch') { self::appendHint($hints, $seen, 'dns-prefetch', $external['url'], null); } if (! empty($external['preconnect'])) { self::appendHint($hints, $seen, 'preconnect', $external['preconnect'], $external['crossorigin'] ?? null); } } return $hints; } /** * @param array> $externals * @return array> */ public static function headLinks(array $externals): array { return array_values(array_filter( $externals, fn (array $external): bool => in_array($external['type'], ['style', 'webfont', 'preload', 'modulepreload'], true) )); } /** * @param array> $externals * @return array> */ public static function scriptsForPosition(array $externals, string $position): array { return array_values(array_filter( $externals, fn (array $external): bool => ($external['type'] ?? null) === 'script' && ($external['position'] ?? 'before-template') === $position )); } /** * @param array $external * @return array */ public static function linkAttributes(array $external): array { $attributes = [ 'rel' => self::linkRel($external['type']), 'href' => $external['url'], ]; self::appendCommonAttributes($attributes, $external); if (in_array($external['type'], ['style', 'webfont'], true)) { self::appendIfString($attributes, 'media', $external['media'] ?? null); } if ($external['type'] === 'preload') { self::appendIfString($attributes, 'as', $external['as'] ?? null); } if (in_array($external['type'], ['preload', 'modulepreload'], true)) { self::appendIfString($attributes, 'type', $external['mimeType'] ?? null); self::appendIfString($attributes, 'fetchpriority', $external['fetchpriority'] ?? null); } return $attributes; } /** * @param array $external * @return array */ public static function scriptAttributes(array $external): array { $attributes = [ 'src' => $external['url'], ]; self::appendCommonAttributes($attributes, $external); if (($external['async'] ?? false) === true) { $attributes['async'] = true; } if (($external['defer'] ?? false) === true) { $attributes['defer'] = true; } return $attributes; } /** * @param array $attributes */ public static function renderAttributes(array $attributes): string { $html = ''; foreach ($attributes as $name => $value) { if ($value === true) { $html .= ' '.e($name); continue; } if (is_string($value) && $value !== '') { $html .= ' '.e($name).'="'.e($value).'"'; } } return $html; } private static function normalizeItem(array $external): ?array { $type = $external['type'] ?? null; $url = $external['url'] ?? null; if (! is_string($type) || ! in_array($type, self::TYPES, true) || ! self::isHttpsUrl($url)) { return null; } if ($type === 'preload' && empty($external['as'])) { Log::warning('Template external preload skipped because as is missing.', ['url' => $url]); return null; } $item = [ 'type' => $type, 'url' => $url, ]; self::appendId($item, $external['id'] ?? null); self::appendCrossorigin($item, $external['crossorigin'] ?? null); self::appendIfAllowed($item, 'integrity', $external['integrity'] ?? null, ['style', 'webfont', 'script', 'preload', 'modulepreload'], $type); self::appendIfAllowed($item, 'media', $external['media'] ?? null, ['style', 'webfont'], $type); self::appendIfAllowed($item, 'as', $external['as'] ?? null, ['preload'], $type); self::appendIfAllowed($item, 'mimeType', $external['mimeType'] ?? null, ['preload', 'modulepreload'], $type); if (isset($external['referrerpolicy']) && is_string($external['referrerpolicy']) && in_array($external['referrerpolicy'], self::REFERRER_POLICIES, true) && in_array($type, ['style', 'webfont', 'script', 'preload', 'modulepreload'], true) ) { $item['referrerpolicy'] = $external['referrerpolicy']; } if (isset($external['fetchpriority']) && is_string($external['fetchpriority']) && in_array($external['fetchpriority'], ['high', 'low', 'auto'], true) && in_array($type, ['preload', 'modulepreload'], true) ) { $item['fetchpriority'] = $external['fetchpriority']; } if (isset($external['preconnect']) && is_string($external['preconnect']) && self::isHttpsUrl($external['preconnect']) && in_array($type, ['style', 'webfont', 'script', 'preload', 'modulepreload'], true) ) { $item['preconnect'] = $external['preconnect']; } if ($type === 'script') { if (($external['async'] ?? false) === true && ($external['defer'] ?? false) === true) { Log::warning('Template external script skipped because async and defer are both true.', ['url' => $url]); return null; } if (isset($external['position']) && ! in_array($external['position'], self::SCRIPT_POSITIONS, true)) { Log::warning('Template external script skipped because position is invalid.', ['url' => $url]); return null; } $item['position'] = $external['position'] ?? 'before-template'; $item['async'] = ($external['async'] ?? false) === true; $item['defer'] = ($external['defer'] ?? false) === true; } return $item; } /** * @param array> $hints * @param array $seen */ private static function appendHint(array &$hints, array &$seen, string $type, string $url, mixed $crossorigin): void { $key = $type.'|'.$url; if (isset($seen[$key])) { return; } $hint = [ 'type' => $type, 'url' => $url, ]; self::appendCrossorigin($hint, $crossorigin); $seen[$key] = true; $hints[] = $hint; } private static function linkRel(string $type): string { return match ($type) { 'preconnect' => 'preconnect', 'dns-prefetch' => 'dns-prefetch', 'preload' => 'preload', 'modulepreload' => 'modulepreload', default => 'stylesheet', }; } private static function appendCommonAttributes(array &$attributes, array $external): void { self::appendIfString($attributes, 'id', $external['id'] ?? null); self::appendIfString($attributes, 'crossorigin', $external['crossorigin'] ?? null); self::appendIfString($attributes, 'integrity', $external['integrity'] ?? null); self::appendIfString($attributes, 'referrerpolicy', $external['referrerpolicy'] ?? null); } private static function appendId(array &$item, mixed $value): void { if (is_string($value) && preg_match('/^[A-Za-z0-9_-]+$/', $value) === 1) { $item['id'] = $value; } } private static function appendCrossorigin(array &$item, mixed $value): void { if ($value === true) { $item['crossorigin'] = 'anonymous'; return; } if (in_array($value, ['anonymous', 'use-credentials'], true)) { $item['crossorigin'] = $value; } } private static function appendIfAllowed(array &$item, string $key, mixed $value, array $allowedTypes, string $type): void { if (is_string($value) && in_array($type, $allowedTypes, true)) { $item[$key] = $value; } } private static function appendIfString(array &$attributes, string $key, mixed $value): void { if (is_string($value) && $value !== '') { $attributes[$key] = $value; } } private static function isHttpsUrl(mixed $value): bool { return is_string($value) && str_starts_with($value, 'https://'); } }