diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 9199cfffe3..8b5209eab1 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -467,9 +467,13 @@ phutil_register_library_map(array( 'DivinerAtomRef' => 'applications/diviner/atom/DivinerAtomRef.php', 'DivinerAtomizeWorkflow' => 'applications/diviner/workflow/DivinerAtomizeWorkflow.php', 'DivinerAtomizer' => 'applications/diviner/atomizer/DivinerAtomizer.php', + 'DivinerDefaultRenderer' => 'applications/diviner/renderer/DivinerDefaultRenderer.php', 'DivinerFileAtomizer' => 'applications/diviner/atomizer/DivinerFileAtomizer.php', 'DivinerGenerateWorkflow' => 'applications/diviner/workflow/DivinerGenerateWorkflow.php', 'DivinerListController' => 'applications/diviner/controller/DivinerListController.php', + 'DivinerPublisher' => 'applications/diviner/publisher/DivinerPublisher.php', + 'DivinerRenderer' => 'applications/diviner/renderer/DivinerRenderer.php', + 'DivinerStaticPublisher' => 'applications/diviner/publisher/DivinerStaticPublisher.php', 'DivinerWorkflow' => 'applications/diviner/workflow/DivinerWorkflow.php', 'DrydockAllocatorWorker' => 'applications/drydock/worker/DrydockAllocatorWorker.php', 'DrydockApacheWebrootInterface' => 'applications/drydock/interface/webroot/DrydockApacheWebrootInterface.php', @@ -1962,9 +1966,11 @@ phutil_register_library_map(array( 'DiffusionView' => 'AphrontView', 'DivinerArticleAtomizer' => 'DivinerAtomizer', 'DivinerAtomizeWorkflow' => 'DivinerWorkflow', + 'DivinerDefaultRenderer' => 'DivinerRenderer', 'DivinerFileAtomizer' => 'DivinerAtomizer', 'DivinerGenerateWorkflow' => 'DivinerWorkflow', 'DivinerListController' => 'PhabricatorController', + 'DivinerStaticPublisher' => 'DivinerPublisher', 'DivinerWorkflow' => 'PhutilArgumentWorkflow', 'DrydockAllocatorWorker' => 'PhabricatorWorker', 'DrydockApacheWebrootInterface' => 'DrydockWebrootInterface', diff --git a/src/applications/diviner/atom/DivinerAtom.php b/src/applications/diviner/atom/DivinerAtom.php index aa8de92cf6..24a8328f04 100644 --- a/src/applications/diviner/atom/DivinerAtom.php +++ b/src/applications/diviner/atom/DivinerAtom.php @@ -22,15 +22,31 @@ final class DivinerAtom { private $context; private $extends = array(); private $links = array(); - private $project; + private $book; - public function setProject($project) { - $this->project = $project; + /** + * Returns a sorting key which imposes an unambiguous, stable order on atoms. + */ + public function getSortKey() { + return implode( + "\0", + array( + $this->getBook(), + $this->getType(), + $this->getContext(), + $this->getName(), + $this->getFile(), + sprintf('%08', $this->getLine()), + )); + } + + public function setBook($book) { + $this->book = $book; return $this; } - public function getProject() { - return $this->project; + public function getBook() { + return $this->book; } public function setContext($context) { @@ -84,6 +100,18 @@ final class DivinerAtom { return $this->docblockMeta; } + public function getDocblockMetaValue($key, $default = null) { + $meta = $this->getDocblockMeta(); + return idx($meta, $key, $default); + } + + public function setDocblockMetaValue($key, $value) { + $meta = $this->getDocblockMeta(); + $meta[$key] = $value; + $this->docblockMeta = $meta; + return $this; + } + public function setType($type) { $this->type = $type; return $this; @@ -235,6 +263,7 @@ final class DivinerAtom { // getAtomSerializationVersion(). return array( + 'book' => $this->getBook(), 'type' => $this->getType(), 'name' => $this->getName(), 'file' => $this->getFile(), @@ -256,7 +285,7 @@ final class DivinerAtom { public function getRef() { return id(new DivinerAtomRef()) - ->setProject($this->getProject()) + ->setBook($this->getBook()) ->setContext($this->getContext()) ->setType($this->getType()) ->setName($this->getName()); @@ -264,6 +293,7 @@ final class DivinerAtom { public static function newFromDictionary(array $dictionary) { $atom = id(new DivinerAtom()) + ->setBook(idx($dictionary, 'book')) ->setType(idx($dictionary, 'type')) ->setName(idx($dictionary, 'name')) ->setFile(idx($dictionary, 'file')) diff --git a/src/applications/diviner/atom/DivinerAtomRef.php b/src/applications/diviner/atom/DivinerAtomRef.php index 0ee5188c8c..d177c748b7 100644 --- a/src/applications/diviner/atom/DivinerAtomRef.php +++ b/src/applications/diviner/atom/DivinerAtomRef.php @@ -2,12 +2,18 @@ final class DivinerAtomRef { - private $project; + private $book; private $context; private $type; private $name; public function setName($name) { + $normal_name = self::normalizeString($name); + if (preg_match('/^@[0-9]+$/', $normal_name)) { + throw new Exception( + "Atom names must not be in the form '/@\d+/'. This pattern is ". + "reserved for disambiguating atoms with similar names."); + } $this->name = $name; return $this; } @@ -17,7 +23,7 @@ final class DivinerAtomRef { } public function setType($type) { - $this->type = $type; + $this->type = self::normalizeString($type); return $this; } @@ -26,7 +32,11 @@ final class DivinerAtomRef { } public function setContext($context) { - $this->context = $context; + if ($context === null) { + $this->context = $context; + } else { + $this->context = self::normalizeString($context); + } return $this; } @@ -34,18 +44,18 @@ final class DivinerAtomRef { return $this->context; } - public function setProject($project) { - $this->project = $project; + public function setBook($book) { + $this->book = self::normalizeString($book); return $this; } - public function getProject() { - return $this->project; + public function getBook() { + return $this->book; } public function toDictionary() { return array( - 'project' => $this->getProject(), + 'book' => $this->getBook(), 'context' => $this->getContext(), 'type' => $this->getType(), 'name' => $this->getName(), @@ -60,10 +70,57 @@ final class DivinerAtomRef { public static function newFromDictionary(array $dict) { $obj = new DivinerAtomRef(); - $obj->project = idx($dict, 'project'); + $obj->book = idx($dict, 'book'); $obj->context = idx($dict, 'context'); $obj->type = idx($dict, 'type'); $obj->name = idx($dict, 'name'); return $obj; } + + public static function normalizeString($str) { + // These characters create problems on the filesystem or in URIs. Replace + // them with non-problematic appoximations (instead of simply removing them) + // to keep the URIs fairly useful and avoid unnecessary collisions. These + // approximations are selected based on some domain knowledge of common + // languages: where a character is used as a delimiter, it is more helpful + // to replace it with a "." or a ":" or similar, while it's better if + // operator overloads read as, e.g., "operator_div". + + $map = array( + // Hopefully not used anywhere by anything. + '#' => '.', + + // Used in Ruby methods. + '?' => 'Q', + + // Used in PHP namespaces. + '\\' => '.', + + // Used in "operator +" in C++. + '+' => 'plus', + + // Used in "operator %" in C++. + '%' => 'mod', + + // Used in "operator /" in C++. + '/' => 'div', + ); + $str = str_replace(array_keys($map), array_values($map), $str); + + // Replace all spaces with underscores. + $str = preg_replace('/ +/', '_', $str); + + // Replace control characters with "@". + $str = preg_replace('/[\x00-\x19]/', '@', $str); + + // Replace specific problematic names with alternative names. + $alternates = array( + '.' => 'dot', + '..' => 'dotdot', + '' => 'null', + ); + + return idx($alternates, $str, $str); + } + } diff --git a/src/applications/diviner/atomizer/DivinerArticleAtomizer.php b/src/applications/diviner/atomizer/DivinerArticleAtomizer.php index 3718319219..c18061b301 100644 --- a/src/applications/diviner/atomizer/DivinerArticleAtomizer.php +++ b/src/applications/diviner/atomizer/DivinerArticleAtomizer.php @@ -12,12 +12,22 @@ final class DivinerArticleAtomizer extends DivinerAtomizer { $atom->setDocblockRaw($block); $meta = $atom->getDocblockMeta(); + $title = idx($meta, 'title'); if (!strlen($title)) { - $title = 'Untitled Article "'.basename($file_name).'"'; + $title = pht('Untitled Article "%s"', basename($file_name)); $atom->addWarning("Article has no @title!"); + $atom->setDocblockMetaValue('title', $title); } - $atom->setName($title); + + // If the article has no @name, use the filename after stripping any + // extension. + $name = idx($meta, 'name'); + if (!$name) { + $name = basename($file_name); + $name = preg_replace('/\\.[^.]+$/', '', $name); + } + $atom->setName($name); return array($atom); } diff --git a/src/applications/diviner/atomizer/DivinerAtomizer.php b/src/applications/diviner/atomizer/DivinerAtomizer.php index ba53c8cf4d..7f26a6312b 100644 --- a/src/applications/diviner/atomizer/DivinerAtomizer.php +++ b/src/applications/diviner/atomizer/DivinerAtomizer.php @@ -5,7 +5,7 @@ */ abstract class DivinerAtomizer { - private $project; + private $book; /** * If you make a significant change to an atomizer, you can bump this @@ -17,26 +17,26 @@ abstract class DivinerAtomizer { abstract public function atomize($file_name, $file_data); - final public function setProject($project) { - $this->project = $project; + final public function setBook($book) { + $this->book = $book; return $this; } - final public function getProject() { - return $this->project; + final public function getBook() { + return $this->book; } protected function newAtom($type) { return id(new DivinerAtom()) - ->setProject($this->getProject()) + ->setBook($this->getBook()) ->setType($type); } - protected function newRef($type, $name, $project = null, $context = null) { - $project = coalesce($project, $this->getProject()); + protected function newRef($type, $name, $book = null, $context = null) { + $book = coalesce($book, $this->getBook()); return id(new DivinerAtomRef()) - ->setProject($project) + ->setBook($book) ->setContext($context) ->setType($type) ->setName($name); diff --git a/src/applications/diviner/publisher/DivinerPublisher.php b/src/applications/diviner/publisher/DivinerPublisher.php new file mode 100644 index 0000000000..f586cb88ba --- /dev/null +++ b/src/applications/diviner/publisher/DivinerPublisher.php @@ -0,0 +1,132 @@ +renderer = $renderer; + return $this; + } + + public function getRenderer() { + return $this->renderer; + } + + public function setConfig(array $config) { + $this->config = $config; + return $this; + } + + public function getConfig($key, $default = null) { + return idx($this->config, $key, $default); + } + + public function setAtomCache(DivinerAtomCache $cache) { + $this->atomCache = $cache; + $graph_map = $this->atomCache->getGraphMap(); + $this->atomGraphHashToNodeHashMap = array_flip($graph_map); + } + + protected function getAtomFromGraphHash($graph_hash) { + if (empty($this->atomGraphHashToNodeHashMap[$graph_hash])) { + throw new Exception("No such atom '{$graph_hash}'!"); + } + + return $this->getAtomFromNodeHash( + $this->atomGraphHashToNodeHashMap[$graph_hash]); + } + + protected function getAtomFromNodeHash($node_hash) { + if (empty($this->atomMap[$node_hash])) { + $dict = $this->atomCache->getAtom($node_hash); + $this->atomMap[$node_hash] = DivinerAtom::newFromDictionary($dict); + } + return $this->atomMap[$node_hash]; + } + + protected function getSimilarAtoms(DivinerAtom $atom) { + if ($this->symbolReverseMap === null) { + $rmap = array(); + $smap = $this->atomCache->getSymbolMap(); + foreach ($smap as $nhash => $shash) { + $rmap[$shash][$nhash] = true; + } + $this->symbolReverseMap = $rmap; + } + + $shash = $atom->getRef()->toHash(); + + if (empty($this->symbolReverseMap[$shash])) { + throw new Exception("Atom has no symbol map entry!"); + } + + $hashes = $this->symbolReverseMap[$shash]; + + $atoms = array(); + foreach ($hashes as $hash => $ignored) { + $atoms[] = $this->getAtomFromNodeHash($hash); + } + + $atoms = msort($atoms, 'getSortKey'); + return $atoms; + } + + /** + * If a book contains multiple definitions of some atom, like some function + * "f()", we assign them an arbitrary (but fairly stable) order and publish + * them as "function/f/1/", "function/f/2/", etc., or similar. + */ + protected function getAtomSimilarIndex(DivinerAtom $atom) { + $atoms = $this->getSimilarAtoms($atom); + if (count($atoms) == 1) { + return null; + } + + $index = 1; + foreach ($atoms as $similar_atom) { + if ($atom === $similar_atom) { + return $index; + } + $index++; + } + + throw new Exception("Expected to find atom while disambiguating!"); + } + + + abstract protected function loadAllPublishedHashes(); + abstract protected function deleteDocumentsByHash(array $hashes); + abstract protected function createDocumentsByHash(array $hashes); + + final public function publishAtoms(array $hashes) { + $existing = $this->loadAllPublishedHashes(); + + $existing_map = array_fill_keys($existing, true); + $hashes_map = array_fill_keys($hashes, true); + + $deleted = array_diff_key($existing_map, $hashes_map); + $created = array_diff_key($hashes_map, $existing_map); + + $this->createDocumentsByHash(array_keys($created)); + $this->deleteDocumentsByHash(array_keys($deleted)); + } + + protected function shouldGenerateDocumentForAtom(DivinerAtom $atom) { + switch ($atom->getType()) { + case DivinerAtom::TYPE_FILE: + return false; + case DivinerAtom::TYPE_ARTICLE: + default: + break; + } + + return true; + } + +} diff --git a/src/applications/diviner/publisher/DivinerStaticPublisher.php b/src/applications/diviner/publisher/DivinerStaticPublisher.php new file mode 100644 index 0000000000..baae51f26c --- /dev/null +++ b/src/applications/diviner/publisher/DivinerStaticPublisher.php @@ -0,0 +1,65 @@ +getAtomFromGraphHash($hash); + + if (!$this->shouldGenerateDocumentForAtom($atom)) { + continue; + } + + $content = $this->getRenderer()->renderAtom($atom); + $this->writeDocument($atom, $content); + } + } + + private function writeDocument(DivinerAtom $atom, $content) { + $root = $this->getConfig('root'); + $path = $root.DIRECTORY_SEPARATOR.$this->getAtomRelativePath($atom); + + if (!Filesystem::pathExists($path)) { + Filesystem::createDirectory($path, $umask = 0755, $recursive = true); + } + + Filesystem::writeFile($path.'index.html', $content); + } + + private function getAtomRelativePath(DivinerAtom $atom) { + $ref = $atom->getRef(); + + $book = $ref->getBook(); + $type = $ref->getType(); + $context = $ref->getContext(); + $name = $ref->getName(); + + $path = array( + 'docs', + $book, + $type, + ); + if ($context !== null) { + $path[] = $context; + } + $path[] = $name; + + $index = $this->getAtomSimilarIndex($atom); + if ($index !== null) { + $path[] = '@'.$index; + } + + $path[] = null; + + return implode(DIRECTORY_SEPARATOR, $path); + } + +} diff --git a/src/applications/diviner/renderer/DivinerDefaultRenderer.php b/src/applications/diviner/renderer/DivinerDefaultRenderer.php new file mode 100644 index 0000000000..36e0a29d38 --- /dev/null +++ b/src/applications/diviner/renderer/DivinerDefaultRenderer.php @@ -0,0 +1,9 @@ +getType()." ".$atom->getName()."!"; + } + +} diff --git a/src/applications/diviner/renderer/DivinerRenderer.php b/src/applications/diviner/renderer/DivinerRenderer.php new file mode 100644 index 0000000000..7858c8854c --- /dev/null +++ b/src/applications/diviner/renderer/DivinerRenderer.php @@ -0,0 +1,7 @@ +setArguments( array( array( - 'name' => 'atomizer', - 'param' => 'class', - 'help' => 'Specify a subclass of DivinerAtomizer.', + 'name' => 'atomizer', + 'param' => 'class', + 'help' => pht('Specify a subclass of DivinerAtomizer.'), ), array( - 'name' => 'files', - 'wildcard' => true, + 'name' => 'book', + 'param' => 'path', + 'help' => pht('Path to a Diviner book configuration.'), ), array( - 'name' => 'ugly', - 'help' => 'Produce ugly (but faster) output.', + 'name' => 'files', + 'wildcard' => true, + ), + array( + 'name' => 'ugly', + 'help' => pht('Produce ugly (but faster) output.'), ), )); } public function execute(PhutilArgumentParser $args) { + $this->readBookConfiguration($args); + $console = PhutilConsole::getConsole(); $atomizer_class = $args->getArg('atomizer'); @@ -81,6 +88,11 @@ final class DivinerAtomizeWorkflow extends DivinerWorkflow { } $all_atoms = array_mergev($all_atoms); + + foreach ($all_atoms as $atom) { + $atom->setBook($this->getConfig('name')); + } + $all_atoms = mpull($all_atoms, 'toDictionary'); $all_atoms = ipull($all_atoms, null, 'hash'); diff --git a/src/applications/diviner/workflow/DivinerGenerateWorkflow.php b/src/applications/diviner/workflow/DivinerGenerateWorkflow.php index 8b75f45145..573e1dcfa1 100644 --- a/src/applications/diviner/workflow/DivinerGenerateWorkflow.php +++ b/src/applications/diviner/workflow/DivinerGenerateWorkflow.php @@ -2,7 +2,6 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow { - private $config; private $atomCache; public function didConstruct() { @@ -23,10 +22,6 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow { )); } - protected function getConfig($key, $default = null) { - return idx($this->config, $key, $default); - } - protected function getAtomCache() { if (!$this->atomCache) { $book_root = $this->getConfig('root'); @@ -49,6 +44,7 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow { if ($args->getArg('clean')) { $this->log(pht('CLEARING CACHES')); $this->getAtomCache()->delete(); + $this->log(pht('Done.')."\n"); } // The major challenge of documentation generation is one of dependency @@ -134,6 +130,8 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow { $this->buildAtomCache(); $this->buildGraphCache(); + + $this->publishDocumentation(); } /* -( Atom Cache )--------------------------------------------------------- */ @@ -167,7 +165,7 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow { $this->getAtomCache()->saveAtoms(); - $this->log(pht("Done.")); + $this->log(pht('Done.')."\n"); } private function getAtomizersForFiles(array $files) { @@ -252,8 +250,9 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow { foreach ($atomizers as $class => $files) { foreach (array_chunk($files, 32) as $chunk) { $future = new ExecFuture( - '%s atomize --ugly --atomizer %s -- %Ls', + '%s atomize --ugly --book %s --atomizer %s -- %Ls', dirname(phutil_get_library_root('phabricator')).'/bin/diviner', + $this->getBookConfigPath(), $class, $chunk); $future->setCWD($this->getConfig('root')); @@ -352,9 +351,25 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow { $this->log(pht('Propagating changes through the graph.')); - foreach ($dirty_symbols as $symbol => $ignored) { - foreach ($atom_cache->getEdgesWithDestination($symbol) as $edge) { + // Find all the nodes which point at a dirty node, and dirty them. Then + // find all the nodes which point at those nodes and dirty them, and so + // on. (This is slightly overkill since we probably don't need to propagate + // dirtiness across documentation "links" between symbols, but we do want + // to propagate it across "extends", and we suffer only a little bit of + // collateral damage by over-dirtying as long as the documentation isn't + // too well-connected.) + + $symbol_stack = array_keys($dirty_symbols); + while ($symbol_stack) { + $symbol_hash = array_pop($symbol_stack); + + foreach ($atom_cache->getEdgesWithDestination($symbol_hash) as $edge) { $dirty_nhashes[$edge] = true; + $src_hash = $this->computeSymbolHash($edge); + if (empty($dirty_symbols[$src_hash])) { + $dirty_symbols[$src_hash] = true; + $symbol_stack[] = $src_hash; + } } } @@ -370,7 +385,7 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow { $atom_cache->saveEdges(); $atom_cache->saveSymbols(); - $this->log(pht('Done.')); + $this->log(pht('Done.')."\n"); } private function computeSymbolHash($node_hash) { @@ -386,9 +401,15 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow { $atom = $atom_cache->getAtom($node_hash); $refs = array(); + + // Make the atom depend on its own symbol, so that all atoms with the same + // symbol are dirtied (e.g., if a codebase defines the function "f()" + // several times, all of them should be dirtied when one is dirtied). + $refs[DivinerAtomRef::newFromDictionary($atom)->toHash()] = true; + foreach (array_merge($atom['extends'], $atom['links']) as $ref_dict) { $ref = DivinerAtomRef::newFromDictionary($ref_dict); - if ($ref->getProject() == $atom['project']) { + if ($ref->getBook() == $atom['book']) { $refs[$ref->toHash()] = true; } } @@ -411,42 +432,21 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow { return md5(serialize($inputs)).'G'; } - private function readBookConfiguration(PhutilArgumentParser $args) { - $book_path = $args->getArg('book'); - if ($book_path === null) { - throw new PhutilArgumentUsageException( - "Specify a Diviner book configuration file with --book."); - } - $book_data = Filesystem::readFile($book_path); - $book = json_decode($book_data, true); - if (!is_array($book)) { - throw new PhutilArgumentUsageException( - "Book configuration '{$book_path}' is not in JSON format."); - } + private function publishDocumentation() { + $atom_cache = $this->getAtomCache(); + $graph_map = $atom_cache->getGraphMap(); - // If the book specifies a "root", resolve it; otherwise, use the directory - // the book configuration file lives in. - $full_path = dirname(Filesystem::resolvePath($book_path)); - if (empty($book['root'])) { - $book['root'] = '.'; - } - $book['root'] = Filesystem::resolvePath($book['root'], $full_path); + $this->log(pht('PUBLISHING DOCUMENTATION')); - // Make sure we have a valid book name. - if (!isset($book['name'])) { - throw new PhutilArgumentUsageException( - "Book configuration '{$book_path}' is missing required ". - "property 'name'."); - } + $publisher = new DivinerStaticPublisher(); + $publisher->setConfig($this->getAllConfig()); + $publisher->setAtomCache($atom_cache); + $publisher->setRenderer(new DivinerDefaultRenderer()); + $publisher->publishAtoms(array_values($graph_map)); - if (!preg_match('/^[a-z][a-z-]*$/', $book['name'])) { - $name = $book['name']; - throw new PhutilArgumentUsageException( - "Book configuration '{$book_path}' has name '{$name}', but book names ". - "must include only lowercase letters and hyphens."); - } - - $this->config = $book; + $this->log(pht('Done.')); } + + } diff --git a/src/applications/diviner/workflow/DivinerWorkflow.php b/src/applications/diviner/workflow/DivinerWorkflow.php index 07d09be03b..1b64e8aa39 100644 --- a/src/applications/diviner/workflow/DivinerWorkflow.php +++ b/src/applications/diviner/workflow/DivinerWorkflow.php @@ -2,8 +2,63 @@ abstract class DivinerWorkflow extends PhutilArgumentWorkflow { + private $config; + private $bookConfigPath; + + public function getBookConfigPath() { + return $this->bookConfigPath; + } + public function isExecutable() { return true; } + protected function getConfig($key, $default = null) { + return idx($this->config, $key, $default); + } + + protected function getAllConfig() { + return $this->config; + } + + protected function readBookConfiguration(PhutilArgumentParser $args) { + $book_path = $args->getArg('book'); + if ($book_path === null) { + throw new PhutilArgumentUsageException( + "Specify a Diviner book configuration file with --book."); + } + + $book_data = Filesystem::readFile($book_path); + $book = json_decode($book_data, true); + if (!is_array($book)) { + throw new PhutilArgumentUsageException( + "Book configuration '{$book_path}' is not in JSON format."); + } + + // If the book specifies a "root", resolve it; otherwise, use the directory + // the book configuration file lives in. + $full_path = dirname(Filesystem::resolvePath($book_path)); + if (empty($book['root'])) { + $book['root'] = '.'; + } + $book['root'] = Filesystem::resolvePath($book['root'], $full_path); + + // Make sure we have a valid book name. + if (!isset($book['name'])) { + throw new PhutilArgumentUsageException( + "Book configuration '{$book_path}' is missing required ". + "property 'name'."); + } + + if (!preg_match('/^[a-z][a-z-]*$/', $book['name'])) { + $name = $book['name']; + throw new PhutilArgumentUsageException( + "Book configuration '{$book_path}' has name '{$name}', but book names ". + "must include only lowercase letters and hyphens."); + } + + $this->bookConfigPath = $book_path; + $this->config = $book; + } + }