Skip to content

Commit

Permalink
Merge pull request #2046 from hydephp/blade-table-of-contents-system
Browse files Browse the repository at this point in the history
[2.x] Refactor the internals of the new table of contents generator
  • Loading branch information
caendesilva authored Dec 1, 2024
2 parents 1c13043 + ff5ffef commit 166a551
Show file tree
Hide file tree
Showing 3 changed files with 149 additions and 42 deletions.
2 changes: 2 additions & 0 deletions docs/creating-content/documentation-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ In the `config/docs.php` file you can configure the behaviour, content, and the
],
```

To customize the markup or styles of the table of contents, you can publish the `x-hyde::docs.table-of-contents` Blade component and modify it to your liking.

### Using Flattened Output Paths

If this setting is set to true, Hyde will output all documentation pages into the same configured documentation output directory. This means that you can use the automatic directory-based grouping feature, but still have a "flat" output structure. Note that this means that you can't have two documentation pages with the same filename or navigation menu label as they will overwrite each other.
Expand Down
1 change: 0 additions & 1 deletion docs/digging-deeper/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ You can read more about some of these in the [Core Concepts](core-concepts#paths
| Overriding Hyde views is not working | Ensure the Blade views are in the correct directory. | Rerun `php hyde publish:views`. |
| Styles not updating when deploying site | It could be a caching issue. To be honest, when dealing with styles, it's always a caching issue. | Clear your cache, and optionally complain to your site host |
| Documentation sidebar items are in the wrong order | Double check the config, make sure the route keys are written correctly. Check that you are not overriding with front matter. | Check config for typos and front matter |
| Documentation table of contents is weird | The table of contents markup is generated by the [League/CommonMark extension](https://commonmark.thephpleague.com/2.3/extensions/table-of-contents/) | Make sure that your Markdown headings make sense |
| Issues with date in blog post front matter | The date is parsed by the PHP `strtotime()` function. The date may be in an invalid format, or the front matter is invalid | Ensure the date is in a format that `strtotime()` can parse. Wrap the front matter value in quotes. |
| RSS feed not being generated | The RSS feed requires that you have set a site URL in the Hyde config or the `.env` file. Also check that you have blog posts, and that they are enabled. | Check your configuration files. | |
| Sitemap not being generated | The sitemap requires that you have set a site URL in the Hyde config or the `.env` file. | Check your configuration files. | |
Expand Down
188 changes: 147 additions & 41 deletions packages/framework/src/Framework/Actions/GeneratesTableOfContents.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
use Hyde\Markdown\Models\Markdown;
use Illuminate\Support\Str;

/**
* Generates a nested table of contents from Markdown headings.
*/
class GeneratesTableOfContents
{
protected string $markdown;
Expand All @@ -18,78 +21,181 @@ class GeneratesTableOfContents
public function __construct(Markdown|string $markdown)
{
$this->markdown = (string) $markdown;

$this->minHeadingLevel = Config::getInt('docs.sidebar.table_of_contents.min_heading_level', 2);
$this->maxHeadingLevel = Config::getInt('docs.sidebar.table_of_contents.max_heading_level', 4);
}

/**
* @return array<int, array{title: string, slug: string, children: array}>
*/
public function execute(): array
{
$headings = $this->parseHeadings();

return $this->buildTableOfContents($headings);
return $this->buildTableOfContents($this->parseHeadings());
}

/**
* @return array<int, array{level: int, title: string, slug: string}>
*/
protected function parseHeadings(): array
{
// Match both ATX-style (###) and Setext-style (===, ---) headers
$pattern = '/^(?:#{1,6}\s+(.+)|(.+)\n([=\-])\3+)$/m';
preg_match_all($pattern, $this->markdown, $matches);

$matches = $this->matchHeadingPatterns();
$headings = [];

foreach ($matches[0] as $index => $heading) {
// Handle ATX-style headers (###)
if (str_starts_with($heading, '#')) {
$level = substr_count($heading, '#');
$title = $matches[1][$index];
}
// Handle Setext-style headers (=== or ---)
else {
$title = trim($matches[2][$index]);
$level = $matches[3][$index] === '=' ? 1 : 2;
// Only add if the config level is met
if ($level < $this->minHeadingLevel) {
continue;
}
$headingData = $this->parseHeadingData($heading, $matches, $index);

if ($headingData === null) {
continue;
}

$slug = Str::slug($title);
$headings[] = [
'level' => $level,
'title' => $title,
'slug' => $slug,
];
$headings[] = $this->createHeadingEntry($headingData);
}

return $headings;
}

/**
* @return array{0: array<int, string>, 1: array<int, string>, 2: array<int, string>, 3: array<int, string>}
*/
protected function matchHeadingPatterns(): array
{
// Match both ATX-style (###) and Setext-style (===, ---) headers
$pattern = '/^(?:#{1,6}\s+(.+)|(.+)\n([=\-])\3+)$/m';
preg_match_all($pattern, $this->markdown, $matches);

return $matches;
}

/**
* @param array{0: array<int, string>, 1: array<int, string>, 2: array<int, string>, 3: array<int, string>} $matches
* @return array{level: int, title: string}|null
*/
protected function parseHeadingData(string $heading, array $matches, int $index): ?array
{
if (str_starts_with($heading, '#')) {
return $this->parseAtxHeading($heading, $matches[1][$index]);
}

return $this->parseSetextHeading($matches[2][$index], $matches[3][$index]);
}

/**
* @return array{level: int, title: string}
*/
protected function parseAtxHeading(string $heading, string $title): array
{
return [
'level' => substr_count($heading, '#'),
'title' => $title,
];
}

/**
* @return array{level: int, title: string}|null
*/
protected function parseSetextHeading(string $title, string $marker): ?array
{
$level = $marker === '=' ? 1 : 2;

if ($level < $this->minHeadingLevel) {
return null;
}

return [
'level' => $level,
'title' => trim($title),
];
}

/**
* @param array{level: int, title: string} $headingData
* @return array{level: int, title: string, slug: string}
*/
protected function createHeadingEntry(array $headingData): array
{
return [
'level' => $headingData['level'],
'title' => $headingData['title'],
'slug' => Str::slug($headingData['title']),
];
}

/**
* @param array<int, array{level: int, title: string, slug: string}> $headings
* @return array<int, array{title: string, slug: string, children: array}>
*/
protected function buildTableOfContents(array $headings): array
{
$items = [];
$stack = [&$items];
$previousLevel = $this->minHeadingLevel;

foreach ($headings as $heading) {
if ($heading['level'] < $this->minHeadingLevel || $heading['level'] > $this->maxHeadingLevel) {
continue;
if ($this->isHeadingWithinBounds($heading)) {
$item = $this->createTableItem($heading);
$this->updateStackForHeadingLevel($stack, $heading['level'], $previousLevel);

$stack[count($stack) - 1][] = $item;
$previousLevel = $heading['level'];
}
}

$item = [
'title' => $heading['title'],
'slug' => $heading['slug'],
'children' => [],
];
return $items;
}

if ($heading['level'] > $previousLevel) {
$stack[] = &$stack[count($stack) - 1][count($stack[count($stack) - 1]) - 1]['children'];
} elseif ($heading['level'] < $previousLevel) {
array_splice($stack, $heading['level'] - $this->minHeadingLevel + 1);
}
/**
* @param array{level: int, title: string, slug: string} $heading
*/
protected function isHeadingWithinBounds(array $heading): bool
{
return $heading['level'] >= $this->minHeadingLevel &&
$heading['level'] <= $this->maxHeadingLevel;
}

$stack[count($stack) - 1][] = $item;
$previousLevel = $heading['level'];
/**
* @param array{level: int, title: string, slug: string} $heading
* @return array{title: string, slug: string, children: array}
*/
protected function createTableItem(array $heading): array
{
return [
'title' => $heading['title'],
'slug' => $heading['slug'],
'children' => [],
];
}

/**
* @param array<int, array<int, array{title: string, slug: string, children: array}>> $stack
*/
protected function updateStackForHeadingLevel(array &$stack, int $currentLevel, int $previousLevel): void
{
if ($currentLevel > $previousLevel) {
$this->nestNewLevel($stack);
}

return $items;
if ($currentLevel < $previousLevel) {
$this->unwindStack($stack, $currentLevel);
}
}

/**
* @param array<int, array<int, array{title: string, slug: string, children: array}>> $stack
*/
protected function nestNewLevel(array &$stack): void
{
$lastStackIndex = count($stack) - 1;
$lastItemIndex = count($stack[$lastStackIndex]) - 1;

$stack[] = &$stack[$lastStackIndex][$lastItemIndex]['children'];
}

/**
* @param array<int, array<int, array{title: string, slug: string, children: array}>> $stack
*/
protected function unwindStack(array &$stack, int $currentLevel): void
{
array_splice($stack, $currentLevel - $this->minHeadingLevel + 1);
}
}

0 comments on commit 166a551

Please sign in to comment.