Skip to content

Commit

Permalink
Merge pull request #2045 from hydephp/blade-table-of-contents-system
Browse files Browse the repository at this point in the history
[2.x] Blade-based table of contents generator
  • Loading branch information
caendesilva authored Dec 1, 2024
2 parents f93a7ba + ccd2942 commit 1c13043
Show file tree
Hide file tree
Showing 14 changed files with 1,034 additions and 365 deletions.
23 changes: 23 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ This serves two purposes:

- Breaking: Removed the build task `\Hyde\Framework\Actions\PostBuildTasks\GenerateSearch` (see upgrade guide below)
- Breaking: Removed the deprecated `\Hyde\Framework\Services\BuildService::transferMediaAssets()` method (see upgrade guide below)
- Breaking: Removed the `DocumentationPage::getTableOfContents()` method as we now use Blade to generate the table of contents in https://github.com/hydephp/develop/pull/2045
- Removed the deprecated global `unslash()` function, replaced with the namespaced `\Hyde\unslash()` function in https://github.com/hydephp/develop/pull/1754
- Removed the deprecated `BaseUrlNotSetException` class, with the `Hyde::url()` helper now throwing `BadMethodCallException` if no base URL is set in https://github.com/hydephp/develop/pull/1760
- Removed: The deprecated `PostAuthor::getName()` method is now removed (use `$author->name`) in https://github.com/hydephp/develop/pull/1782
Expand Down Expand Up @@ -500,6 +501,28 @@ Hyperlinks::isRemote($source);

This change was implemented in https://github.com/hydephp/develop/pull/1883. Make sure to update any instances of `FeaturedImage::isRemote()` in your codebase to ensure compatibility with HydePHP v2.0.

### Blade-based table of contents generator

The way we generate table of contents for documentation pages have been changed from a helper method to a Blade component.

This new system is much easier to customize and style, and is up to 40 times faster than the old system.

See https://github.com/hydephp/develop/pull/2045 for more information.

#### Scope

The likelihood of impact is low, but if any of the following are true, you may need to update your code:

- If you have used the `Hyde\Framework\Actions\GeneratesTableOfContents` class in custom code, you will likely need to update that code for the rewritten class.
- If you have called the `getTableOfContents` method of the `DocumentationPage` class in custom code, you will need to update that usage as the that message has been removed.
- If you have published the `resources/views/components/docs/sidebar-item.blade.php` component, you will need to update it to call the new component instead of the old generator rendering.

#### Changes
- Adds a new `resources/views/components/docs/table-of-contents.blade.php` component containing the structure and styles for the table of contents
- Rewrites the `GeneratesTableOfContents` class to use a custom implementation instead of using CommonMark
- The `execute` method of the `GeneratesTableOfContents` class now returns an array of data, instead of a string of HTML. This data should be fed into the new component
- Removed the `table-of-contents.css` file as styles are now made using Tailwind

## New features

<!-- Editors note: Todo: Maybe move to the relevant docs... -->
Expand Down
2 changes: 1 addition & 1 deletion _media/app.css

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,6 @@ No description provided.
$page->getOnlineSourcePath(): string|false
```

#### `getTableOfContents()`

Generate Table of Contents as HTML from a Markdown document body.

```php
$page->getTableOfContents(): string
```

#### `getRouteKey()`

Get the route key for the page.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@props(['grouped' => false])
@use('Hyde\Framework\Actions\GeneratesTableOfContents')
@php /** @var \Hyde\Framework\Features\Navigation\NavigationItem $item */ @endphp
<li @class(['sidebar-item -ml-4 pl-4', $grouped
? 'active -ml-8 pl-8 bg-black/5 dark:bg-black/10'
Expand All @@ -14,7 +15,7 @@

@if(config('docs.sidebar.table_of_contents.enabled', true))
<span class="sr-only">Table of contents</span>
{!! $page->getTableOfContents() !!}
<x-hyde::docs.table-of-contents :items="(new GeneratesTableOfContents($page->markdown))->execute()" />
@endif
@else
<a href="{{ $item->getLink() }}" @class([$grouped
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@props(['items', 'isChild' => false])

@if(! empty($items))
<ul class="{{ ! $isChild ? 'table-of-contents pb-3' : 'pl-2' }}">
@foreach($items as $item)
<li class="my-0.5">
<a href="#{{ $item['slug'] }}" class="-ml-8 pl-8 opacity-80 hover:opacity-100 hover:bg-gray-200/20 transition-all duration-300">
<span class="text-[75%] opacity-50 hover:opacity-100 transition-opacity duration-300">#</span>
{{ $item['title'] }}
</a>

@if(! empty($item['children']))
<x-hyde::docs.table-of-contents :items="$item['children']" :isChild="true" />
@endif
</li>
@endforeach
</ul>
@endif
114 changes: 70 additions & 44 deletions packages/framework/src/Framework/Actions/GeneratesTableOfContents.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,64 +6,90 @@

use Hyde\Facades\Config;
use Hyde\Markdown\Models\Markdown;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
use League\CommonMark\Extension\TableOfContents\TableOfContentsExtension;
use League\CommonMark\MarkdownConverter;

use function strpos;
use function substr;

/**
* Generates a table of contents for the Markdown document, most commonly used for the sidebar.
*/
use Illuminate\Support\Str;

class GeneratesTableOfContents
{
protected string $markdown;

protected int $minHeadingLevel = 2;
protected int $maxHeadingLevel = 4;

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);
}

public function execute(): string
public function execute(): array
{
$config = [
'table_of_contents' => [
'html_class' => 'table-of-contents',
'position' => 'placeholder',
'placeholder' => '[[START_TOC]]',
'style' => 'bullet',
'min_heading_level' => Config::getInt('docs.sidebar.table_of_contents.min_heading_level', 2),
'max_heading_level' => Config::getInt('docs.sidebar.table_of_contents.max_heading_level', 4),
'normalize' => 'relative',
],
'heading_permalink' => [
'fragment_prefix' => '',
],
];

$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new HeadingPermalinkExtension());
$environment->addExtension(new TableOfContentsExtension());

$converter = new MarkdownConverter($environment);
$html = $converter->convert($this->markdown."\n[[START_TOC]]")->getContent();

return $this->extractTableOfContents($html);
$headings = $this->parseHeadings();

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

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);

$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;
}
}

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

return $headings;
}

protected function extractTableOfContents(string $html): string
protected function buildTableOfContents(array $headings): array
{
// The table of contents is always at the end of the document, so we can just strip everything before it.
$position = strpos($html, '<ul class="table-of-contents">');
if ($position === false) {
// The document has no headings, so we'll just return an empty string.
return '';
$items = [];
$stack = [&$items];
$previousLevel = $this->minHeadingLevel;

foreach ($headings as $heading) {
if ($heading['level'] < $this->minHeadingLevel || $heading['level'] > $this->maxHeadingLevel) {
continue;
}

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

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);
}

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

return substr($html, $position);
return $items;
}
}
9 changes: 0 additions & 9 deletions packages/framework/src/Pages/DocumentationPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use Hyde\Facades\Config;
use Hyde\Foundation\Facades\Routes;
use Hyde\Framework\Actions\GeneratesTableOfContents;
use Hyde\Pages\Concerns\BaseMarkdownPage;
use Hyde\Support\Models\Route;

Expand Down Expand Up @@ -54,14 +53,6 @@ public static function hasTableOfContents(): bool
return Config::getBool('docs.sidebar.table_of_contents.enabled', true);
}

/**
* Generate Table of Contents as HTML from a Markdown document body.
*/
public function getTableOfContents(): string
{
return (new GeneratesTableOfContents($this->markdown))->execute();
}

/**
* Get the route key for the page.
*
Expand Down
Loading

0 comments on commit 1c13043

Please sign in to comment.