diff --git a/src/infrastructure/celerity/CelerityResourceTransformer.php b/src/infrastructure/celerity/CelerityResourceTransformer.php index 9ba6805d6e..8ef35da29a 100644 --- a/src/infrastructure/celerity/CelerityResourceTransformer.php +++ b/src/infrastructure/celerity/CelerityResourceTransformer.php @@ -1,212 +1,226 @@ translateURICallback = $translate_uricallback; return $this; } public function setMinify($minify) { $this->minify = $minify; return $this; } public function setRawResourceMap(array $raw_resource_map) { $this->rawResourceMap = $raw_resource_map; return $this; } public function setCelerityMap(CelerityResourceMap $celerity_map) { $this->celerityMap = $celerity_map; return $this; } + public function setRawURIMap(array $raw_urimap) { + $this->rawURIMap = $raw_urimap; + return $this; + } + + public function getRawURIMap() { + return $this->rawURIMap; + } + /** * @phutil-external-symbol function jsShrink */ public function transformResource($path, $data) { $type = self::getResourceType($path); switch ($type) { case 'css': $data = $this->replaceCSSPrintRules($path, $data); $data = $this->replaceCSSVariables($path, $data); $data = preg_replace_callback( '@url\s*\((\s*[\'"]?.*?)\)@s', nonempty( $this->translateURICallback, array($this, 'translateResourceURI')), $data); break; } if (!$this->minify) { return $data; } // Some resources won't survive minification (like Raphael.js), and are // marked so as not to be minified. if (strpos($data, '@'.'do-not-minify') !== false) { return $data; } switch ($type) { case 'css': // Remove comments. $data = preg_replace('@/\*.*?\*/@s', '', $data); // Remove whitespace around symbols. $data = preg_replace('@\s*([{}:;,])\s*@', '\1', $data); // Remove unnecessary semicolons. $data = preg_replace('@;}@', '}', $data); // Replace #rrggbb with #rgb when possible. $data = preg_replace( '@#([a-f0-9])\1([a-f0-9])\2([a-f0-9])\3@i', '#\1\2\3', $data); $data = trim($data); break; case 'js': // If `jsxmin` is available, use it. jsxmin is the Javelin minifier and // produces the smallest output, but is complicated to build. if (Filesystem::binaryExists('jsxmin')) { $future = new ExecFuture('jsxmin __DEV__:0'); $future->write($data); list($err, $result) = $future->resolve(); if (!$err) { $data = $result; break; } } // If `jsxmin` is not available, use `JsShrink`, which doesn't compress // quite as well but is always available. $root = dirname(phutil_get_library_root('phabricator')); require_once $root.'/externals/JsShrink/jsShrink.php'; $data = jsShrink($data); break; } return $data; } public static function getResourceType($path) { return last(explode('.', $path)); } public function translateResourceURI(array $matches) { $uri = trim($matches[1], "'\" \r\t\n"); - if ($this->rawResourceMap) { + if ($this->rawURIMap !== null) { + if (isset($this->rawURIMap[$uri])) { + $uri = $this->rawURIMap[$uri]; + } + } else if ($this->rawResourceMap) { if (isset($this->rawResourceMap[$uri]['uri'])) { $uri = $this->rawResourceMap[$uri]['uri']; } } else if ($this->celerityMap) { $info = $this->celerityMap->lookupFileInformation($uri); if ($info) { $uri = $info['uri']; } } return 'url('.$uri.')'; } private function replaceCSSVariables($path, $data) { $this->currentPath = $path; return preg_replace_callback( '/{\$([^}]+)}/', array($this, 'replaceCSSVariable'), $data); } private function replaceCSSPrintRules($path, $data) { $this->currentPath = $path; return preg_replace_callback( '/!print\s+(.+?{.+?})/s', array($this, 'replaceCSSPrintRule'), $data); } public static function getCSSVariableMap() { return array( // Base Colors 'red' => '#c0392b', 'lightred' => '#f4dddb', 'orange' => '#e67e22', 'lightorange' => '#f7e2d4', 'yellow' => '#f1c40f', 'lightyellow' => '#fdf5d4', 'green' => '#139543', 'lightgreen' => '#d7eddf', 'blue' => '#2980b9', 'lightblue' => '#daeaf3', 'sky' => '#3498db', 'lightsky' => '#ddeef9', 'indigo' => '#c6539d', 'lightindigo' => '#f5e2ef', 'violet' => '#8e44ad', 'lightviolet' => '#ecdff1', 'charcoal' => '#4b4d51', 'backdrop' => '#c4cde0', // Base Greys 'lightgreyborder' => '#C7CCD9', 'greyborder' => '#A1A6B0', 'darkgreyborder' => '#676A70', 'lightgreytext' => '#92969D', 'greytext' => '#74777D', 'darkgreytext' => '#4B4D51', 'lightgreybackground' => '#F7F7F7', 'greybackground' => '#EBECEE', // Base Blues 'thinblueborder' => '#DDE8EF', 'lightblueborder' => '#BFCFDA', 'blueborder' => '#8C98B8', 'darkblueborder' => '#626E82', 'lightbluebackground' => '#F8F9FC', 'bluebackground' => '#DAE7FF', 'lightbluetext' => '#8C98B8', 'bluetext' => '#6B748C', 'darkbluetext' => '#464C5C', ); } public function replaceCSSVariable($matches) { static $map; if (!$map) { $map = self::getCSSVariableMap(); } $var_name = $matches[1]; if (empty($map[$var_name])) { $path = $this->currentPath; throw new Exception( "CSS file '{$path}' has unknown variable '{$var_name}'."); } return $map[$var_name]; } public function replaceCSSPrintRule($matches) { $rule = $matches[1]; $rules = array(); $rules[] = '.printable '.$rule; $rules[] = "@media print {\n ".str_replace("\n", "\n ", $rule)."\n}\n"; return implode("\n\n", $rules); } } diff --git a/src/infrastructure/celerity/management/CelerityManagementMapWorkflow.php b/src/infrastructure/celerity/management/CelerityManagementMapWorkflow.php index 5f69baccbc..e5adfe9861 100644 --- a/src/infrastructure/celerity/management/CelerityManagementMapWorkflow.php +++ b/src/infrastructure/celerity/management/CelerityManagementMapWorkflow.php @@ -1,27 +1,205 @@ setName('map') ->setExamples('**map** [options]') ->setSynopsis(pht('Rebuild static resource maps.')) ->setArguments( array()); } public function execute(PhutilArgumentParser $args) { $resources_map = CelerityResources::getAll(); foreach ($resources_map as $name => $resources) { - // TODO: This does not do anything useful yet. - var_dump($resources->findBinaryResources()); - var_dump($resources->findTextResources()); + $this->rebuildResources($resources); } return 0; } + /** + * Rebuild the resource map for a resource source. + * + * @param CelerityResources Resource source to rebuild. + * @return void + */ + private function rebuildResources(CelerityResources $resources) { + $binary_map = $this->rebuildBinaryResources($resources); + + $xformer = id(new CelerityResourceTransformer()) + ->setMinify(false) + ->setRawURIMap(ipull($binary_map, 'uri')); + + $text_map = $this->rebuildTextResources($resources, $xformer); + + $resource_graph = array(); + $requires_map = array(); + $provides_map = array(); + foreach ($text_map as $name => $info) { + if (isset($info['provides'])) { + $provides_map[$info['provides']] = $info['hash']; + + // We only need to check for cycles and add this to the requires map + // if it actually requires anything. + if (!empty($info['requires'])) { + $resource_graph[$info['provides']] = $info['requires']; + $requires_map[$info['hash']] = $info['requires']; + } + } + } + + $this->detectGraphCycles($resource_graph); + + $hash_map = ipull($binary_map, 'hash') + ipull($text_map, 'hash'); + + + // TODO: Actually do things. + + var_dump($provides_map); + var_dump($requires_map); + var_dump($hash_map); + } + + + /** + * Find binary resources (like PNG and SWF) and return information about + * them. + * + * @param CelerityResources Resource map to find binary resources for. + * @return map> Resource information map. + */ + private function rebuildBinaryResources(CelerityResources $resources) { + $binary_map = $resources->findBinaryResources(); + + $result_map = array(); + foreach ($binary_map as $name => $data_hash) { + $hash = $resources->getCelerityHash($data_hash.$name); + + $result_map[$name] = array( + 'hash' => $hash, + 'uri' => $resources->getResourceURI($hash, $name), + ); + } + + return $result_map; + } + + + /** + * Find text resources (like JS and CSS) and return information about them. + * + * @param CelerityResources Resource map to find text resources for. + * @param CelerityResourceTransformer Configured resource transformer. + * @return map> Resource information map. + */ + private function rebuildTextResources( + CelerityResources $resources, + CelerityResourceTransformer $xformer) { + + $text_map = $resources->findTextResources(); + + $result_map = array(); + foreach ($text_map as $name => $data_hash) { + $raw_data = $resources->getResourceData($name); + $xformed_data = $xformer->transformResource($name, $raw_data); + + $data_hash = $resources->getCelerityHash($xformed_data); + $hash = $resources->getCelerityHash($data_hash.$name); + + list($provides, $requires) = $this->getProvidesAndRequires( + $name, + $raw_data); + + $result_map[$name] = array( + 'hash' => $hash, + ); + + if ($provides !== null) { + $result_map[$name] += array( + 'provides' => $provides, + 'requires' => $requires, + ); + } + } + + return $result_map; + } + + + /** + * Parse the `@provides` and `@requires` symbols out of a text resource, like + * JS or CSS. + * + * @param string Resource name. + * @param string Resource data. + * @return pair|nul> The `@provides` symbol and the + * list of `@requires` symbols. If the resource is not part of the + * dependency graph, both are null. + */ + private function getProvidesAndRequires($name, $data) { + $parser = new PhutilDocblockParser(); + + $matches = array(); + $ok = preg_match('@/[*][*].*?[*]/@s', $data, $matches); + if (!$ok) { + throw new Exception( + pht( + 'Resource "%s" does not have a header doc comment. Encode '. + 'dependency data in a header docblock.', + $name)); + } + + list($description, $metadata) = $parser->parse($matches[0]); + + $provides = preg_split('/\s+/', trim(idx($metadata, 'provides'))); + $requires = preg_split('/\s+/', trim(idx($metadata, 'requires'))); + $provides = array_filter($provides); + $requires = array_filter($requires); + + if (!$provides) { + // Tests and documentation-only JS is permitted to @provide no targets. + return array(null, null); + } + + if (count($provides) > 1) { + throw new Exception( + pht( + 'Resource "%s" must @provide at most one Celerity target.', + $name)); + } + + return array(head($provides), $requires); + } + + + /** + * Check for dependency cycles in the resource graph. Raises an exception if + * a cycle is detected. + * + * @param map> Map of `@provides` symbols to their + * `@requires` symbols. + * @return void + */ + private function detectGraphCycles(array $nodes) { + $graph = id(new CelerityResourceGraph()) + ->addNodes($nodes) + ->setResourceGraph($nodes) + ->loadGraph(); + + foreach ($nodes as $provides => $requires) { + $cycle = $graph->detectCycles($provides); + if ($cycle) { + throw new Exception( + pht( + 'Cycle detected in resource graph: %s', + implode(' > ', $cycle))); + } + } + } + } diff --git a/src/infrastructure/celerity/resources/CelerityResources.php b/src/infrastructure/celerity/resources/CelerityResources.php index e7253ead8a..f512f7f10f 100644 --- a/src/infrastructure/celerity/resources/CelerityResources.php +++ b/src/infrastructure/celerity/resources/CelerityResources.php @@ -1,47 +1,58 @@ setAncestorClass('CelerityResources') ->loadObjects(); foreach ($resources_list as $resources) { $name = $resources->getName(); if (empty($resources_map[$name])) { $resources_map[$name] = $resources; } else { $old = get_class($resources_map[$name]); $new = get_class($resources); throw new Exception( pht( 'Celerity resource maps must have unique names, but maps %s and '. '%s share the same name, "%s".', $old, $new, $name)); } } } return $resources_map; } } diff --git a/src/infrastructure/celerity/resources/CelerityResourcesOnDisk.php b/src/infrastructure/celerity/resources/CelerityResourcesOnDisk.php index 47c91b48c0..81d14947bb 100644 --- a/src/infrastructure/celerity/resources/CelerityResourcesOnDisk.php +++ b/src/infrastructure/celerity/resources/CelerityResourcesOnDisk.php @@ -1,57 +1,61 @@ getPathToResources().'/'.$name); + } + public function findBinaryResources() { return $this->findResourcesWithSuffixes($this->getBinaryFileSuffixes()); } public function findTextResources() { return $this->findResourcesWithSuffixes($this->getTextFileSuffixes()); } protected function getBinaryFileSuffixes() { return array( 'png', 'jpg', 'gif', 'swf', ); } protected function getTextFileSuffixes() { return array( 'js', 'css', ); } private function findResourcesWithSuffixes(array $suffixes) { $root = $this->getPathToResources(); $finder = id(new FileFinder($root)) ->withType('f') ->withFollowSymlinks(true) ->setGenerateChecksums(true); foreach ($suffixes as $suffix) { $finder->withSuffix($suffix); } $raw_files = $finder->find(); $results = array(); foreach ($raw_files as $path => $hash) { - $readable = '/'.Filesystem::readablePath($path, $root); + $readable = Filesystem::readablePath($path, $root); $results[$readable] = $hash; } return $results; } }