@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
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}