diff --git a/composer.json b/composer.json index b40331d..e8b4fd4 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,11 @@ "SymfonyDocsBuilder\\": "src" } }, + "autoload-dev": { + "psr-4": { + "SymfonyDocsBuilder\\Tests\\": "tests" + } + }, "require": { "ext-json": "*", "ext-curl": "*", diff --git a/composer.lock b/composer.lock index 2081a58..14cabce 100644 --- a/composer.lock +++ b/composer.lock @@ -106,12 +106,12 @@ "source": { "type": "git", "url": "https://github.com/doctrine/rst-parser.git", - "reference": "7907706178f02198a423a907d5aa83dc2a356b70" + "reference": "9f9887b282307c7cc8ff92a2c98aec39346696d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/rst-parser/zipball/7907706178f02198a423a907d5aa83dc2a356b70", - "reference": "7907706178f02198a423a907d5aa83dc2a356b70", + "url": "https://api.github.com/repos/doctrine/rst-parser/zipball/9f9887b282307c7cc8ff92a2c98aec39346696d9", + "reference": "9f9887b282307c7cc8ff92a2c98aec39346696d9", "shasum": "" }, "require": { @@ -130,6 +130,7 @@ "phpstan/phpstan-strict-rules": "^0.12", "phpunit/phpunit": "^7.5 || ^8.0 || ^9.0" }, + "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -164,9 +165,9 @@ ], "support": { "issues": "https://github.com/doctrine/rst-parser/issues", - "source": "https://github.com/doctrine/rst-parser/tree/0.3.x" + "source": "https://github.com/doctrine/rst-parser/tree/0.3.1" }, - "time": "2021-03-08T20:11:41+00:00" + "time": "2021-03-15T18:19:47+00:00" }, { "name": "psr/container", diff --git a/src/BuildResult.php b/src/BuildResult.php index 2d4dccf..a31a71a 100644 --- a/src/BuildResult.php +++ b/src/BuildResult.php @@ -3,6 +3,7 @@ namespace SymfonyDocsBuilder; use Doctrine\RST\Builder; +use Doctrine\RST\Meta\Metas; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Filesystem\Filesystem; use SymfonyDocsBuilder\BuildConfig; @@ -14,11 +15,14 @@ class BuildResult { + private $builder; private $errors; + private $jsonResults = []; - public function __construct(array $errors) + public function __construct(Builder $builder) { - $this->errors = $errors; + $this->builder = $builder; + $this->errors = $builder->getErrorManager()->getErrors(); } public function appendError(string $errorMessage): void @@ -40,4 +44,32 @@ public function getErrors(): array { return $this->errors; } + + public function getMetadata(): Metas + { + return $this->builder->getMetas(); + } + + /** + * Returns the "master document": the first file whose toctree is parsed. + * + * Unless customized, this is "index" (i.e. file index.rst). + */ + public function getMasterDocumentFilename(): string + { + return $this->builder->getIndexName(); + } + + /** + * Returns the JSON array data generated for each file, keyed by the source filename. + */ + public function getJsonResults(): array + { + return $this->jsonResults; + } + + public function setJsonResults(array $jsonResults): void + { + $this->jsonResults = $jsonResults; + } } diff --git a/src/DocBuilder.php b/src/DocBuilder.php index 83eb81e..1f1b079 100644 --- a/src/DocBuilder.php +++ b/src/DocBuilder.php @@ -25,7 +25,7 @@ public function build(BuildConfig $config): BuildResult $builder = new Builder(KernelFactory::createKernel($config)); $builder->build($config->getContentDir(), $config->getOutputDir()); - $buildResult = new BuildResult($builder->getErrorManager()->getErrors()); + $buildResult = new BuildResult($builder); $missingFilesChecker = new MissingFilesChecker($config); $missingFiles = $missingFilesChecker->getMissingFiles(); @@ -38,13 +38,13 @@ public function build(BuildConfig $config): BuildResult $filesystem->dumpFile($config->getOutputDir().'/build_errors.txt', implode("\n", $buildResult->getErrors())); } - $metas = $builder->getMetas(); + $metas = $buildResult->getMetadata(); if ($config->getSubdirectoryToBuild()) { $htmlForPdfGenerator = new HtmlForPdfGenerator($metas, $config); $htmlForPdfGenerator->generateHtmlForPdf(); } else { $jsonGenerator = new JsonGenerator($metas, $config); - $jsonGenerator->generateJson(); + $buildResult->setJsonResults($jsonGenerator->generateJson($builder->getIndexName())); } return $buildResult; diff --git a/src/Generator/JsonGenerator.php b/src/Generator/JsonGenerator.php index 029a614..b24ecc7 100644 --- a/src/Generator/JsonGenerator.php +++ b/src/Generator/JsonGenerator.php @@ -37,29 +37,45 @@ public function __construct(Metas $metas, BuildConfig $buildConfig) $this->buildConfig = $buildConfig; } - public function generateJson() + /** + * Returns an array of each JSON file string, keyed by the input filename + * + * @param string $masterDocument The file whose toctree should be read first + * @return string[] + */ + public function generateJson(string $masterDocument = 'index'): array { $fs = new Filesystem(); $progressBar = new ProgressBar($this->output ?: new NullOutput()); $progressBar->setMaxSteps(\count($this->metas->getAll())); + $walkedFiles = []; + $tocTreeHierarchy = $this->walkTocTreeAndReturnHierarchy( + $masterDocument, + $walkedFiles + ); + // for purposes of prev/next/parents, the "master document" + // behaves as if it's the first item in the toctree + $tocTreeHierarchy = [$masterDocument => []] + $tocTreeHierarchy; + $flattenedTocTree = $this->flattenTocTree($tocTreeHierarchy); + + $fJsonFiles = []; foreach ($this->metas->getAll() as $filename => $metaEntry) { $parserFilename = $filename; $jsonFilename = $this->buildConfig->getOutputDir().'/'.$filename.'.fjson'; $crawler = new Crawler(file_get_contents($this->buildConfig->getOutputDir().'/'.$filename.'.html')); + $next = $this->determineNext($parserFilename, $flattenedTocTree, $masterDocument); + $prev = $this->determinePrev($parserFilename, $flattenedTocTree); $data = [ 'title' => $metaEntry->getTitle(), + 'parents' => $this->determineParents($parserFilename, $tocTreeHierarchy) ?: [], 'current_page_name' => $parserFilename, 'toc' => $this->generateToc($metaEntry, current($metaEntry->getTitles())[1]), - 'next' => $this->guessNext($parserFilename), - 'prev' => $this->guessPrev($parserFilename), - 'rellinks' => [ - $this->guessNext($parserFilename), - $this->guessPrev($parserFilename), - ], + 'next' => $next, + 'prev' => $prev, 'body' => $crawler->filter('body')->html(), ]; @@ -67,11 +83,14 @@ public function generateJson() $jsonFilename, json_encode($data, JSON_PRETTY_PRINT) ); + $fJsonFiles[$filename] = $data; $progressBar->advance(); } $progressBar->finish(); + + return $fJsonFiles; } public function setOutput(SymfonyStyle $output) @@ -100,133 +119,162 @@ private function generateToc(MetaEntry $metaEntry, ?array $titles): array return $tocTree; } - private function guessNext(string $parserFilename): ?array + private function determineNext(string $parserFilename, array $flattenedTocTree): ?array { - $meta = $this->getMetaEntry($parserFilename, true); - - $parentFile = $meta->getParent(); + $foundCurrentFile = false; + $nextFileName = null; - // if current file is an index, next is the first chapter - if ('index' === $parentFile && 1 === \count($tocs = $meta->getTocs()) && \count($tocs[0]) > 0) { - $firstChapterMeta = $this->getMetaEntry($tocs[0][0]); + foreach ($flattenedTocTree as $filename) { + if ($foundCurrentFile) { + $nextFileName = $filename; - if (null === $firstChapterMeta) { - return null; + break; } - return [ - 'title' => $firstChapterMeta->getTitle(), - 'link' => $firstChapterMeta->getUrl(), - ]; + if ($filename === $parserFilename) { + $foundCurrentFile = true; + } } - [$toc, $indexCurrentFile] = $this->getNextPrevInformation($parserFilename); - - if (!isset($toc[$indexCurrentFile + 1])) { + // no next document found! + if (null === $nextFileName) { return null; } - $nextFileName = $toc[$indexCurrentFile + 1]; - - $nextMeta = $this->getMetaEntry($nextFileName); - - if (null === $nextMeta) { - return null; - } + $meta = $this->getMetaEntry($nextFileName); return [ - 'title' => $nextMeta->getTitle(), - 'link' => $nextMeta->getUrl(), + 'title' => $meta->getTitle(), + 'link' => $meta->getUrl(), ]; } - private function guessPrev(string $parserFilename): ?array + private function determinePrev(string $parserFilename, array $flattenedTocTree): ?array { - $meta = $this->getMetaEntry($parserFilename, true); - $parentFile = $meta->getParent(); - - // no prev if parent is an index - if ('index' === $parentFile) { - return null; - } - - [$toc, $indexCurrentFile] = $this->getNextPrevInformation($parserFilename); - - // if current file is the first one of the chapter, prev is the direct parent - if (0 === $indexCurrentFile) { - $parentMeta = $this->getMetaEntry($parentFile); - - if (null === $parentMeta) { - return null; + $previousFileName = null; + $foundCurrentFile = false; + foreach ($flattenedTocTree as $filename) { + if ($filename === $parserFilename) { + $foundCurrentFile = true; + break; } - return [ - 'title' => $parentMeta->getTitle(), - 'link' => $parentMeta->getUrl(), - ]; + $previousFileName = $filename; } - if (!isset($toc[$indexCurrentFile - 1])) { + // no previous document found! + if (null === $previousFileName || !$foundCurrentFile) { return null; } - $prevFileName = $toc[$indexCurrentFile - 1]; - - $prevMeta = $this->getMetaEntry($prevFileName); - - if (null === $prevMeta) { - return null; - } + $meta = $this->getMetaEntry($previousFileName); return [ - 'title' => $prevMeta->getTitle(), - 'link' => $prevMeta->getUrl(), + 'title' => $meta->getTitle(), + 'link' => $meta->getUrl(), ]; } - private function getNextPrevInformation(string $parserFilename): ?array + private function getMetaEntry(string $parserFilename, bool $throwOnMissing = false): ?MetaEntry { - $meta = $this->getMetaEntry($parserFilename, true); - $parentFile = $meta->getParent(); + $metaEntry = $this->metas->get($parserFilename); - if (!$parentFile) { - return [null, null]; - } + // this is possible if there are invalid references + if (null === $metaEntry) { + $message = sprintf('Could not find MetaEntry for file "%s"', $parserFilename); - $metaParent = $this->getMetaEntry($parentFile); + if ($throwOnMissing) { + throw new \Exception($message); + } - if (null === $metaParent || !$metaParent->getTocs() || 1 !== \count($metaParent->getTocs())) { - return [null, null]; + if ($this->output) { + $this->output->note($message); + } } - $toc = current($metaParent->getTocs()); + return $metaEntry; + } - if (\count($toc) < 2 || !isset(array_flip($toc)[$parserFilename])) { - return [null, null]; - } + /** + * Creates a hierarchy of documents by crawling the toctree's + * + * This looks at the + * toc tree of the master document, following the first entry + * like a link, then repeating the process on the next document's + * toc tree (if it has one). When it hits a dead end, it would + * go back to the master document and click the second link. + * But, it skips any links that have been seen before. This + * is the logic behind how the prev/next parent information is created. + * + * Example result: + * [ + * 'dashboards' => [], + * 'design' => [ + * 'crud' => [], + * 'design/sub-page' => [], + * ], + * 'fields' => [] + * ] + * + * See the JsonIntegrationTest for a test case. + */ + private function walkTocTreeAndReturnHierarchy(string $filename, array &$walkedFiles): array + { + $hierarchy = []; + foreach ($this->getMetaEntry($filename)->getTocs() as $toc) { + foreach ($toc as $tocFilename) { + // only walk a file one time, the first time you see it + if (in_array($tocFilename, $walkedFiles, true)) { + continue; + } + + $walkedFiles[] = $tocFilename; - $indexCurrentFile = array_flip($toc)[$parserFilename]; + $hierarchy[$tocFilename] = $this->walkTocTreeAndReturnHierarchy($tocFilename, $walkedFiles); + } + } - return [$toc, $indexCurrentFile]; + return $hierarchy; } - private function getMetaEntry(string $parserFilename, bool $throwOnMissing = false): ?MetaEntry + /** + * Takes the structure from walkTocTreeAndReturnHierarchy() and flattens it. + * + * For example: + * + * [dashboards, design, crud, design/sub-page, fields] + * + * @return string[] + */ + private function flattenTocTree(array $tocTreeHierarchy): array { - $metaEntry = $this->metas->get($parserFilename); + $files = []; - // this is possible if there are invalid references - if (null === $metaEntry) { - $message = sprintf('Could not find MetaEntry for file "%s"', $parserFilename); + foreach ($tocTreeHierarchy as $filename => $tocTree) { + $files[] = $filename; - if ($throwOnMissing) { - throw new \Exception($message); + $files = array_merge($files, $this->flattenTocTree($tocTree)); + } + + return $files; + } + + private function determineParents(string $parserFilename, array $tocTreeHierarchy, array $parents = []): ?array + { + foreach ($tocTreeHierarchy as $filename => $tocTree) { + if ($filename === $parserFilename) { + return $parents; } - if ($this->output) { - $this->output->note($message); + $subParents = $this->determineParents($parserFilename, $tocTree, $parents + [$filename]); + + if (null !== $subParents) { + // the item WAS found and the parents were returned + return $subParents; } } - return $metaEntry; + // item was not found + return null; } } diff --git a/tests/AbstractIntegrationTest.php b/tests/AbstractIntegrationTest.php new file mode 100644 index 0000000..d6719da --- /dev/null +++ b/tests/AbstractIntegrationTest.php @@ -0,0 +1,24 @@ +remove(__DIR__.'/_output'); + + return (new BuildConfig()) + ->setSymfonyVersion('4.0') + ->setContentDir($sourceDir) + ->disableBuildCache() + ->setOutputDir(__DIR__.'/_output') + ; + } +} diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index a3a9c8e..79cdf69 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -9,49 +9,24 @@ namespace SymfonyDocsBuilder\Tests; -use Doctrine\RST\Builder; use Doctrine\RST\Configuration; -use Doctrine\RST\Meta\CachedMetasLoader; -use Doctrine\RST\Meta\Metas; use Doctrine\RST\Parser; use Gajus\Dindent\Indenter; -use PHPUnit\Framework\TestCase; -use Symfony\Component\Console\Helper\ProgressBar; -use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\DomCrawler\Crawler; -use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; -use SymfonyDocsBuilder\BuildConfig; -use SymfonyDocsBuilder\Generator\JsonGenerator; +use SymfonyDocsBuilder\DocBuilder; use SymfonyDocsBuilder\KernelFactory; -class IntegrationTest extends TestCase +class IntegrationTest extends AbstractIntegrationTest { - public function setUp(): void - { - $fs = new Filesystem(); - $fs->remove(__DIR__.'/../var'); - } - /** * @dataProvider integrationProvider */ public function testIntegration(string $folder) { - $fs = new Filesystem(); - $fs->remove([__DIR__.'/_output', __DIR__.'/_cache']); - $fs->mkdir([__DIR__.'/_output', __DIR__.'/_cache']); - $buildConfig = $this->createBuildConfig(sprintf('%s/fixtures/source/%s', __DIR__, $folder)); - - $builder = new Builder( - KernelFactory::createKernel($buildConfig) - ); - - $builder->build( - sprintf('%s/fixtures/source/%s', __DIR__, $folder), - __DIR__.'/_output' - ); + $builder = new DocBuilder(); + $builder->build($buildConfig); $finder = new Finder(); $finder->in(sprintf('%s/fixtures/expected/%s', __DIR__, $folder)) @@ -61,7 +36,7 @@ public function testIntegration(string $folder) $indenter = $this->createIndenter(); foreach ($finder as $expectedFile) { $relativePath = $expectedFile->getRelativePathname(); - $actualFilename = __DIR__.'/_output/'.$relativePath; + $actualFilename = $buildConfig->getOutputDir().'/'.$relativePath; $this->assertFileExists($actualFilename); $this->assertSame( @@ -72,20 +47,9 @@ public function testIntegration(string $folder) ); } - /* - * TODO - get this from the Builder when it is exposed - * https://github.com/doctrine/rst-parser/pull/97 - */ - $metas = new Metas(); - $cachedMetasLoader = new CachedMetasLoader(); - $cachedMetasLoader->loadCachedMetaEntries(__DIR__.'/_output', $metas); - - $jsonGenerator = new JsonGenerator($metas, $buildConfig); - $jsonGenerator->generateJson(new ProgressBar(new NullOutput())); - foreach ($finder as $htmlFile) { $relativePath = $htmlFile->getRelativePathname(); - $actualFilename = __DIR__.'/_output/'.str_replace('.html', '.fjson', $relativePath); + $actualFilename = $buildConfig->getOutputDir().'/'.str_replace('.html', '.fjson', $relativePath); $this->assertFileExists($actualFilename); $jsonData = json_decode(file_get_contents($actualFilename), true); @@ -282,15 +246,6 @@ public function parserUnitBlockProvider() ]; } - private function createBuildConfig(string $sourceDir): BuildConfig - { - return (new BuildConfig()) - ->setSymfonyVersion('4.0') - ->setContentDir($sourceDir) - ->setOutputDir(__DIR__.'/_output') - ->setCacheDir(__DIR__.'/_cache'); - } - private function createIndenter(): Indenter { $indenter = new Indenter(); diff --git a/tests/JsonIntegrationTest.php b/tests/JsonIntegrationTest.php new file mode 100644 index 0000000..c9217c1 --- /dev/null +++ b/tests/JsonIntegrationTest.php @@ -0,0 +1,135 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SymfonyDocsBuilder\Tests; + +use SymfonyDocsBuilder\DocBuilder; + +class JsonIntegrationTest extends AbstractIntegrationTest +{ + /** + * @dataProvider getJsonTests + */ + public function testJsonGeneration(string $filename, array $expectedData) + { + $buildConfig = $this->createBuildConfig(__DIR__ . '/fixtures/source/json'); + $builder = new DocBuilder(); + $buildResult = $builder->build($buildConfig); + $fJsons = $buildResult->getJsonResults(); + + $actualFileData = $fJsons[$filename]; + foreach ($expectedData as $key => $expectedKeyData) { + $this->assertArrayHasKey($key, $actualFileData, sprintf('Missing key "%s" in file "%s"', $key, $filename)); + $this->assertSame($expectedData[$key], $actualFileData[$key], sprintf('Invalid data for key "%s" in file "%s"', $key, $filename)); + } + } + + public function getJsonTests() + { + yield 'index' => [ + 'file' => 'index', + 'data' => [ + 'parents' => [], + 'prev' => null, + 'next' => [ + 'title' => 'Dashboards', + 'link' => 'dashboards.html', + ], + 'title' => 'JSON Generation Test', + ] + ]; + + yield 'dashboards' => [ + 'file' => 'dashboards', + 'data' => [ + 'parents' => [], + 'prev' => [ + 'title' => 'JSON Generation Test', + 'link' => 'index.html', + ], + 'next' => [ + 'title' => 'Design', + 'link' => 'design.html', + ], + 'title' => 'Dashboards', + ] + ]; + + yield 'design' => [ + 'file' => 'design', + 'data' => [ + 'parents' => [], + 'prev' => [ + 'title' => 'Dashboards', + 'link' => 'dashboards.html', + ], + 'next' => [ + 'title' => 'CRUD', + 'link' => 'crud.html', + ], + 'title' => 'Design', + ] + ]; + + yield 'crud' => [ + 'file' => 'crud', + 'data' => [ + 'parents' => ['design'], + 'prev' => [ + 'title' => 'Design', + 'link' => 'design.html', + ], + 'next' => [ + 'title' => 'Design Sub-Page', + 'link' => 'design/sub-page.html', + ], + 'title' => 'CRUD', + ] + ]; + + yield 'design/sub-page' => [ + 'file' => 'design/sub-page', + 'data' => [ + 'parents' => ['design'], + 'prev' => [ + 'title' => 'CRUD', + 'link' => 'crud.html', + ], + 'next' => [ + 'title' => 'Fields', + 'link' => 'fields.html', + ], + 'title' => 'Design Sub-Page', + ] + ]; + + yield 'fields' => [ + 'file' => 'fields', + 'data' => [ + 'parents' => [], + 'prev' => [ + 'title' => 'Design Sub-Page', + 'link' => 'design/sub-page.html', + ], + 'next' => null, + 'title' => 'Fields', + ] + ]; + + yield 'orphan' => [ + 'file' => 'orphan', + 'data' => [ + 'parents' => [], + 'prev' => null, + 'next' => null, + 'title' => 'Orphan', + ] + ]; + } +} diff --git a/tests/fixtures/expected/build-pdf/book.html b/tests/fixtures/expected/build-pdf/book.html index a4e1d46..deb53f8 100644 --- a/tests/fixtures/expected/build-pdf/book.html +++ b/tests/fixtures/expected/build-pdf/book.html @@ -8,8 +8,8 @@
Here is a link to the main index