@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at upstream/main 410 lines 12 kB view raw
1<?php 2 3final class CelerityResourceMapGenerator extends Phobject { 4 5 private $debug = false; 6 private $resources; 7 8 private $nameMap = array(); 9 private $symbolMap = array(); 10 private $requiresMap = array(); 11 private $packageMap = array(); 12 13 public function __construct(CelerityPhysicalResources $resources) { 14 $this->resources = $resources; 15 } 16 17 public function getNameMap() { 18 return $this->nameMap; 19 } 20 21 public function getSymbolMap() { 22 return $this->symbolMap; 23 } 24 25 public function getRequiresMap() { 26 return $this->requiresMap; 27 } 28 29 public function getPackageMap() { 30 return $this->packageMap; 31 } 32 33 public function setDebug($debug) { 34 $this->debug = $debug; 35 return $this; 36 } 37 38 protected function log($message) { 39 if ($this->debug) { 40 $console = PhutilConsole::getConsole(); 41 $console->writeErr("%s\n", $message); 42 } 43 } 44 45 public function generate() { 46 $binary_map = $this->rebuildBinaryResources($this->resources); 47 48 $this->log(pht('Found %d binary resources.', count($binary_map))); 49 50 $xformer = id(new CelerityResourceTransformer()) 51 ->setMinify(false) 52 ->setRawURIMap(ipull($binary_map, 'uri')); 53 54 $text_map = $this->rebuildTextResources($this->resources, $xformer); 55 56 $this->log(pht('Found %d text resources.', count($text_map))); 57 58 $resource_graph = array(); 59 $requires_map = array(); 60 $symbol_map = array(); 61 foreach ($text_map as $name => $info) { 62 if (isset($info['provides'])) { 63 $symbol_map[$info['provides']] = $info['hash']; 64 65 // We only need to check for cycles and add this to the requires map 66 // if it actually requires anything. 67 if (!empty($info['requires'])) { 68 $resource_graph[$info['provides']] = $info['requires']; 69 $requires_map[$info['hash']] = $info['requires']; 70 } 71 } 72 } 73 74 $this->detectGraphCycles($resource_graph); 75 $name_map = ipull($binary_map, 'hash') + ipull($text_map, 'hash'); 76 $hash_map = array_flip($name_map); 77 78 $package_map = $this->rebuildPackages( 79 $this->resources, 80 $symbol_map, 81 $hash_map); 82 83 $this->log(pht('Found %d packages.', count($package_map))); 84 85 $component_map = array(); 86 foreach ($package_map as $package_name => $package_info) { 87 foreach ($package_info['symbols'] as $symbol) { 88 $component_map[$symbol] = $package_name; 89 } 90 } 91 92 $name_map = $this->mergeNameMaps( 93 array( 94 array(pht('Binary'), ipull($binary_map, 'hash')), 95 array(pht('Text'), ipull($text_map, 'hash')), 96 array(pht('Package'), ipull($package_map, 'hash')), 97 )); 98 $package_map = ipull($package_map, 'symbols'); 99 100 ksort($name_map, SORT_STRING); 101 ksort($symbol_map, SORT_STRING); 102 ksort($requires_map, SORT_STRING); 103 ksort($package_map, SORT_STRING); 104 105 $this->nameMap = $name_map; 106 $this->symbolMap = $symbol_map; 107 $this->requiresMap = $requires_map; 108 $this->packageMap = $package_map; 109 110 return $this; 111 } 112 113 public function write() { 114 $map_content = $this->formatMapContent(array( 115 'names' => $this->getNameMap(), 116 'symbols' => $this->getSymbolMap(), 117 'requires' => $this->getRequiresMap(), 118 'packages' => $this->getPackageMap(), 119 )); 120 121 $map_path = $this->resources->getPathToMap(); 122 $this->log(pht('Writing map "%s".', Filesystem::readablePath($map_path))); 123 Filesystem::writeFile($map_path, $map_content); 124 125 return $this; 126 } 127 128 private function formatMapContent(array $data) { 129 $content = phutil_var_export($data); 130 $generated = '@'.'generated'; 131 132 return <<<EOFILE 133<?php 134 135/** 136 * This file is automatically generated. Use 'bin/celerity map' to rebuild it. 137 * 138 * {$generated} 139 */ 140return {$content}; 141 142EOFILE; 143 } 144 145 /** 146 * Find binary resources (like PNG and SWF) and return information about 147 * them. 148 * 149 * @param CelerityPhysicalResources $resources Resource map to find binary 150 * resources for. 151 * @return map<string, map<string, string>> Resource information map. 152 */ 153 private function rebuildBinaryResources( 154 CelerityPhysicalResources $resources) { 155 156 $binary_map = $resources->findBinaryResources(); 157 $result_map = array(); 158 159 foreach ($binary_map as $name => $data_hash) { 160 $hash = $this->newResourceHash($data_hash.$name); 161 162 $result_map[$name] = array( 163 'hash' => $hash, 164 'uri' => $resources->getResourceURI($hash, $name), 165 ); 166 } 167 168 return $result_map; 169 } 170 171 /** 172 * Find text resources (like JS and CSS) and return information about them. 173 * 174 * @param CelerityPhysicalResources $resources Resource map to find text 175 * resources for. 176 * @param CelerityResourceTransformer $xformer Configured resource 177 * transformer. 178 * @return map<string, map<string, string>> Resource information map. 179 */ 180 private function rebuildTextResources( 181 CelerityPhysicalResources $resources, 182 CelerityResourceTransformer $xformer) { 183 184 $text_map = $resources->findTextResources(); 185 $result_map = array(); 186 187 foreach ($text_map as $name => $data_hash) { 188 $raw_data = $resources->getResourceData($name); 189 $xformed_data = $xformer->transformResource($name, $raw_data); 190 191 $data_hash = $this->newResourceHash($xformed_data); 192 $hash = $this->newResourceHash($data_hash.$name); 193 194 list($provides, $requires) = $this->getProvidesAndRequires( 195 $name, 196 $raw_data); 197 198 $result_map[$name] = array( 199 'hash' => $hash, 200 ); 201 202 if ($provides !== null) { 203 $result_map[$name] += array( 204 'provides' => $provides, 205 'requires' => $requires, 206 ); 207 } 208 } 209 210 return $result_map; 211 } 212 213 /** 214 * Parse the `@provides` and `@requires` symbols out of a text resource, like 215 * JS or CSS. 216 * 217 * @param string $name Resource name. 218 * @param string $data Resource data. 219 * @return array<string|null, list<string>|null> The `@provides` symbol and 220 * the list of `@requires` symbols. If the resource is not part of the 221 * dependency graph, both are null. 222 */ 223 private function getProvidesAndRequires($name, $data) { 224 $parser = new PhutilDocblockParser(); 225 226 $matches = array(); 227 $ok = preg_match('@/[*][*].*?[*]/@s', $data, $matches); 228 if (!$ok) { 229 throw new Exception( 230 pht( 231 'Resource "%s" does not have a header doc comment. Encode '. 232 'dependency data in a header docblock.', 233 $name)); 234 } 235 236 list($description, $metadata) = $parser->parse($matches[0]); 237 238 $provides = $this->parseResourceSymbolList(idx($metadata, 'provides')); 239 $requires = $this->parseResourceSymbolList(idx($metadata, 'requires')); 240 if (!$provides) { 241 // Tests and documentation-only JS is permitted to @provide no targets. 242 return array(null, null); 243 } 244 245 if (count($provides) > 1) { 246 throw new Exception( 247 pht( 248 'Resource "%s" must %s at most one Celerity target.', 249 $name, 250 '@provide')); 251 } 252 253 return array(head($provides), $requires); 254 } 255 256 /** 257 * Check for dependency cycles in the resource graph. Raises an exception if 258 * a cycle is detected. 259 * 260 * @param map<string, list<string>> $nodes Map of `@provides` symbols to 261 * their `@requires` symbols. 262 * @return void 263 */ 264 private function detectGraphCycles(array $nodes) { 265 $graph = id(new CelerityResourceGraph()) 266 ->addNodes($nodes) 267 ->setResourceGraph($nodes) 268 ->loadGraph(); 269 270 foreach ($nodes as $provides => $requires) { 271 $cycle = $graph->detectCycles($provides); 272 if ($cycle) { 273 throw new Exception( 274 pht( 275 'Cycle detected in resource graph: %s', 276 implode(' > ', $cycle))); 277 } 278 } 279 } 280 281 /** 282 * Build package specifications for a given resource source. 283 * 284 * @param CelerityPhysicalResources $resources Resource source to rebuild. 285 * @param map<string, string> $symbol_map Map of `@provides` to hashes. 286 * @param map<string, string> $reverse_map Map of hashes to resource names. 287 * @return map<string, map<string, string>> Package information maps. 288 */ 289 private function rebuildPackages( 290 CelerityPhysicalResources $resources, 291 array $symbol_map, 292 array $reverse_map) { 293 294 $package_map = array(); 295 296 $package_spec = $resources->getResourcePackages(); 297 foreach ($package_spec as $package_name => $package_symbols) { 298 $type = null; 299 $hashes = array(); 300 foreach ($package_symbols as $symbol) { 301 $symbol_hash = idx($symbol_map, $symbol); 302 if ($symbol_hash === null) { 303 throw new Exception( 304 pht( 305 'Package specification for "%s" includes "%s", but that symbol '. 306 'is not %s by any resource.', 307 $package_name, 308 $symbol, 309 '@provided')); 310 } 311 312 $resource_name = $reverse_map[$symbol_hash]; 313 $resource_type = $resources->getResourceType($resource_name); 314 if ($type === null) { 315 $type = $resource_type; 316 } else if ($type !== $resource_type) { 317 throw new Exception( 318 pht( 319 'Package specification for "%s" includes resources of multiple '. 320 'types (%s, %s). Each package may only contain one type of '. 321 'resource.', 322 $package_name, 323 $type, 324 $resource_type)); 325 } 326 327 $hashes[] = $symbol.':'.$symbol_hash; 328 } 329 330 $hash = $this->newResourceHash(implode("\n", $hashes)); 331 $package_map[$package_name] = array( 332 'hash' => $hash, 333 'symbols' => $package_symbols, 334 ); 335 } 336 337 return $package_map; 338 } 339 340 private function mergeNameMaps(array $maps) { 341 $result = array(); 342 $origin = array(); 343 344 foreach ($maps as $map) { 345 list($map_name, $data) = $map; 346 foreach ($data as $name => $hash) { 347 if (empty($result[$name])) { 348 $result[$name] = $hash; 349 $origin[$name] = $map_name; 350 } else { 351 $old = $origin[$name]; 352 $new = $map_name; 353 throw new Exception( 354 pht( 355 'Resource source defines two resources with the same name, '. 356 '"%s". One is defined in the "%s" map; the other in the "%s" '. 357 'map. Each resource must have a unique name.', 358 $name, 359 $old, 360 $new)); 361 } 362 } 363 } 364 return $result; 365 } 366 367 private function parseResourceSymbolList($list) { 368 if (!$list) { 369 return array(); 370 } 371 372 // This is valid: 373 // 374 // @requires x y 375 // 376 // But so is this: 377 // 378 // @requires x 379 // @requires y 380 // 381 // Accept either form and produce a list of symbols. 382 383 $list = (array)$list; 384 385 // We can get `true` values if there was a bare `@requires` in the input. 386 foreach ($list as $key => $item) { 387 if ($item === true) { 388 unset($list[$key]); 389 } 390 } 391 392 $list = implode(' ', $list); 393 $list = trim($list); 394 $list = preg_split('/\s+/', $list); 395 $list = array_filter($list); 396 397 return $list; 398 } 399 400 private function newResourceHash($data) { 401 // This HMAC key is a static, hard-coded value because we don't want the 402 // hashes in the map to depend on database state: when two different 403 // developers regenerate the map, they should end up with the same output. 404 405 $hash = PhabricatorHash::digestHMACSHA256($data, 'celerity-resource-data'); 406 407 return substr($hash, 0, 8); 408 } 409 410}