Skip to content

Fix the TOC generation with unique links #156

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/DocsKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use SymfonyDocsBuilder\Listener\AdmonitionListener;
use SymfonyDocsBuilder\Listener\AssetsCopyListener;
use SymfonyDocsBuilder\Listener\CopyImagesListener;
use SymfonyDocsBuilder\Listener\DuplicatedHeaderIdListener;

class DocsKernel extends Kernel
{
Expand Down Expand Up @@ -49,6 +50,11 @@ private function initializeListeners(EventManager $eventManager, ErrorManager $e
new AdmonitionListener()
);

$eventManager->addEventListener(
PreParseDocumentEvent::PRE_PARSE_DOCUMENT,
new DuplicatedHeaderIdListener()
);

$eventManager->addEventListener(
PreNodeRenderEvent::PRE_NODE_RENDER,
new CopyImagesListener($this->buildConfig, $errorManager)
Expand Down
41 changes: 25 additions & 16 deletions src/Generator/JsonGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public function generateJson(string $masterDocument = 'index'): array
$crawler = new Crawler(file_get_contents($this->buildConfig->getOutputDir().'/'.$filename.'.html'));

// happens when some doc is a partial included in other doc an it doesn't have any titles
$toc = false === current($metaEntry->getTitles()) ? [] : $this->generateToc($metaEntry, current($metaEntry->getTitles())[1]);
$toc = $this->generateToc($metaEntry, $crawler);
$next = $this->determineNext($parserFilename, $flattenedTocTree, $masterDocument);
$prev = $this->determinePrev($parserFilename, $flattenedTocTree);
$data = [
Expand Down Expand Up @@ -102,26 +102,35 @@ public function setOutput(SymfonyStyle $output)
$this->output = $output;
}

private function generateToc(MetaEntry $metaEntry, ?array $titles, int $level = 1): array
private function generateToc(MetaEntry $metaEntry, Crawler $crawler): array
{
if (null === $titles) {
return [];
$flatTocTree = [];

foreach ($crawler->filter('h2, h3') as $heading) {
$headerId = $heading->getAttribute('id') ?? Environment::slugify($heading->textContent);

// this tocTree stores items sequentially (h2, h2, h3, h3, h2, h3, etc.)
$flatTocTree[] = [
'level' => 'h2' === $heading->tagName ? 1 : 2,
'url' => sprintf('%s#%s', $metaEntry->getUrl(), $headerId),
'page' => u($metaEntry->getUrl())->beforeLast('.html')->toString(),
'fragment' => $headerId,
'title' => $heading->textContent,
'children' => [],
];
}

$tocTree = [];

foreach ($titles as $title) {
$tocTree[] = [
'level' => $level,
'url' => sprintf('%s#%s', $metaEntry->getUrl(), Environment::slugify($title[0])),
'page' => u($metaEntry->getUrl())->beforeLast('.html'),
'fragment' => Environment::slugify($title[0]),
'title' => $title[0],
'children' => $this->generateToc($metaEntry, $title[1], $level + 1),
];
// this tocTree stores items nested by level (h2, h2[h3, h3], h2[h3], etc.)
$nestedTocTree = [];
foreach ($flatTocTree as $tocItem) {
if (1 === $tocItem['level']) {
$nestedTocTree[] = $tocItem;
} else {
$nestedTocTree[\count($nestedTocTree) - 1]['children'][] = $tocItem;
}
}

return $tocTree;
return $nestedTocTree;
}

private function determineNext(string $parserFilename, array $flattenedTocTree): ?array
Expand Down
25 changes: 25 additions & 0 deletions src/Listener/DuplicatedHeaderIdListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

/*
* This file is part of the Docs Builder package.
* (c) Ryan Weaver <ryan@symfonycasts.com>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace SymfonyDocsBuilder\Listener;

use Doctrine\RST\Event\PreParseDocumentEvent;
use SymfonyDocsBuilder\Renderers\TitleNodeRenderer;

final class DuplicatedHeaderIdListener
{
public function preParseDocument(PreParseDocumentEvent $event): void
{
// needed because we only need to handle duplicated headers within
// the same file, not across all the files being generated
TitleNodeRenderer::resetHeaderIdCache();
}
}
5 changes: 5 additions & 0 deletions src/Renderers/TitleNodeRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public function __construct(TitleNode $titleNode, TemplateRenderer $templateRend
$this->templateRenderer = $templateRenderer;
}

public static function resetHeaderIdCache(): void
{
self::$idUsagesCountByFilename = [];
}

public function render(): string
{
$filename = $this->titleNode->getEnvironment()->getCurrentFileName();
Expand Down
9 changes: 0 additions & 9 deletions tests/IntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,6 @@

class IntegrationTest extends AbstractIntegrationTest
{
public static function setUpBeforeClass(): void
{
$reflection = new \ReflectionClass(TitleNodeRenderer::class);
$property = $reflection->getProperty('idUsagesCountByFilename');
$property->setAccessible(true);

$property->setValue([]);
}

/**
* @dataProvider integrationProvider
*/
Expand Down
49 changes: 47 additions & 2 deletions tests/JsonIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace SymfonyDocsBuilder\Tests;

use SymfonyDocsBuilder\DocBuilder;
use SymfonyDocsBuilder\Renderers\TitleNodeRenderer;

class JsonIntegrationTest extends AbstractIntegrationTest
{
Expand All @@ -26,7 +27,7 @@ public function testJsonGeneration(string $filename, array $expectedData)
$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));
$this->assertSame($expectedKeyData, $actualFileData[$key], sprintf('Invalid data for key "%s" in file "%s"', $key, $filename));
}
}

Expand Down Expand Up @@ -76,9 +77,53 @@ public function getJsonTests()
'title' => 'Design',
'toc_options' => [
'maxDepth' => 2,
'numVisibleItems' => 3,
'numVisibleItems' => 5,
'size' => 'md'
],
'toc' => [
[
'level' => 1,
'url' => 'design.html#section-1',
'page' => 'design',
'fragment' => 'section-1',
'title' => 'Section 1',
'children' => [
[
'level' => 2,
'url' => 'design.html#some-subsection',
'page' => 'design',
'fragment' => 'some-subsection',
'title' => 'Some subsection',
'children' => [],
],
[
'level' => 2,
'url' => 'design.html#some-subsection-1',
'page' => 'design',
'fragment' => 'some-subsection-1',
'title' => 'Some subsection',
'children' => [],
],
],
],
[
'level' => 1,
'url' => 'design.html#section-2',
'page' => 'design',
'fragment' => 'section-2',
'title' => 'Section 2',
'children' => [
[
'level' => 2,
'url' => 'design.html#some-subsection-2',
'page' => 'design',
'fragment' => 'some-subsection-2',
'title' => 'Some subsection',
'children' => [],
],
],
],
],
],
];

Expand Down
16 changes: 14 additions & 2 deletions tests/fixtures/source/json/design.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,30 @@ The toctree below should affects the next/prev. The
first entry is effectively ignored, as it was already
included by the toctree in index.rst (which is parsed first).

Subsection 1
~~~~~~~~~~~~
Some subsection
~~~~~~~~~~~~~~~

This is a subsection of the first section. That's all.

Some subsection
~~~~~~~~~~~~~~~

This sub-section uses the same title as before to test that the tool
never generated two or more headings with the same ID.

Section 2
---------

However, crud (which is ALSO included in the toctree in index.rst),
WILL be read here, as the "crud" in index.rst has not been read
yet (design comes first). Also, design/sub-page WILL be considered.

Some subsection
~~~~~~~~~~~~~~~

This sub-section also uses the same title as in the previous section
to test that the tool never generated two or more headings with the same ID.

.. toctree::
:maxdepth: 1

Expand Down