Skip to content

Commit

Permalink
Merge pull request #2047 from hydephp/custom-markdown-heading-renderer
Browse files Browse the repository at this point in the history
[2.x] Custom Markdown heading renderer
  • Loading branch information
caendesilva authored Dec 5, 2024
2 parents 166a551 + 3df7aa2 commit 62f5092
Show file tree
Hide file tree
Showing 17 changed files with 842 additions and 111 deletions.
6 changes: 6 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ This serves two purposes:
- Added new `npm run build` command for compiling frontend assets with Vite
- Added a Vite HMR support for the realtime compiler in https://github.com/hydephp/develop/pull/2016
- Added Vite facade in https://github.com/hydephp/develop/pull/2016
- Added a custom Blade-based heading renderer for Markdown conversions in https://github.com/hydephp/develop/pull/2047

### Changed

Expand Down Expand Up @@ -104,6 +105,8 @@ This serves two purposes:
- Normalized default Tailwind Typography Prose code block styles to match Torchlight's theme, ensuring consistent styling across Markdown and Torchlight code blocks in https://github.com/hydephp/develop/pull/2036.
- Extracted CSS component partials in HydeFront in https://github.com/hydephp/develop/pull/2038
- Replaced HydeFront styles with Tailwind in https://github.com/hydephp/develop/pull/2024
- Markdown headings are now compiled using our custom Blade-based heading renderer in https://github.com/hydephp/develop/pull/2047
- The `id` attributes for heading permalinks have been moved from the anchor to the heading element in https://github.com/hydephp/develop/pull/2052

### Deprecated

Expand Down Expand Up @@ -132,6 +135,8 @@ This serves two purposes:
- This also removes the `<x-hyde::docs.search-input />` and `<x-hyde::docs.search-scripts />` Blade components, replaced by the new `<x-hyde::docs.hyde-search />` component.
- Removed the `.torchlight-enabled` CSS class in https://github.com/hydephp/develop/pull/2036.
- Removed The `hyde.css` file from HydeFront in https://github.com/hydephp/develop/pull/2037 as all styles were refactored to Tailwind in https://github.com/hydephp/develop/pull/2024.
- Removed the `MarkdownService::withPermalinks` method in https://github.com/hydephp/develop/pull/2047
- Removed the `MarkdownService::canEnablePermalinks` method in https://github.com/hydephp/develop/pull/2047

### Fixed

Expand Down Expand Up @@ -522,6 +527,7 @@ The likelihood of impact is low, but if any of the following are true, you may n
- 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
- Removed the `heading-permalinks.css` file as styles are now made using Tailwind

## New features

Expand Down
2 changes: 1 addition & 1 deletion _media/app.css

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions config/markdown.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,22 @@
*/

'prose_classes' => 'prose dark:prose-invert',

/*
|--------------------------------------------------------------------------
| Heading Permalinks Configuration
|--------------------------------------------------------------------------
|
| Here you can specify which page classes should have heading permalinks.
| By default, only documentation pages have permalinks enabled, but you
| are free to enable it for any kind of page by adding the page class.
|
*/

'permalinks' => [
'pages' => [
\Hyde\Pages\DocumentationPage::class,
],
],

];
30 changes: 30 additions & 0 deletions docs/digging-deeper/advanced-markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,36 @@ anything within the path label will be rendered as HTML. This means you can add
The filepaths are hidden on mobile devices using CSS to prevent them from overlapping with the code block.


## Heading Permalinks

Hyde automatically adds clickable permalink anchors to headings in documentation pages. When you hover over a heading, a `#` link appears that you can click to get a direct link to that section.

### Usage & Configuration

The feature is enabled by default for documentation pages. When enabled, Hyde will automatically add permalink anchors to headings between levels 2-4 (h2-h4). The permalinks are hidden by default and appear when hovering over the heading.

You can enable it for other page types by adding the page class to the `permalinks.pages` array in the `config/markdown.php` file, or disable it for all pages by setting the array to an empty array.

```php
// filepath: config/markdown.php
'permalinks' => [
'pages' => [
\Hyde\Pages\DocumentationPage::class,
],
],
```

### Advanced Customization

Under the hood, Hyde uses a custom Blade-based heading renderer when converting Markdown to HTML. This allows for more flexibility and customization compared to standard Markdown parsers. You can also publish and customize the Blade component used to render the headings:

```bash
php hyde publish:components
```

This will copy the `markdown-heading.blade.php` component to your views directory where you can modify its markup and behavior.


## Dynamic Markdown Links

HydePHP provides a powerful feature for automatically converting Markdown links to source files to the corresponding routes in the built site.
Expand Down
18 changes: 18 additions & 0 deletions packages/framework/config/markdown.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,22 @@
*/

'prose_classes' => 'prose dark:prose-invert',

/*
|--------------------------------------------------------------------------
| Heading Permalinks Configuration
|--------------------------------------------------------------------------
|
| Here you can specify which page classes should have heading permalinks.
| By default, only documentation pages have permalinks enabled, but you
| are free to enable it for any kind of page by adding the page class.
|
*/

'permalinks' => [
'pages' => [
\Hyde\Pages\DocumentationPage::class,
],
],

];
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@props([
'level' => 1,
'id' => null,
'extraAttributes' => [],
'addPermalink' => config('markdown.permalinks.enabled', true),
])

@php
$tag = 'h' . $level;
$id = $id ?? \Illuminate\Support\Str::slug($slot);
$extraAttributes = array_merge($extraAttributes, [
'id' => $addPermalink ? $id : ($extraAttributes['id'] ?? null),
'class' => trim(($extraAttributes['class'] ?? '') . ($addPermalink ? ' group w-fit scroll-mt-2' : '')),
]);
@endphp

<{{ $tag }} {{ $attributes->merge($extraAttributes) }}>
{!! $slot !!}
@if($addPermalink === true)
<a href="#{{ $id }}" class="heading-permalink opacity-0 ml-1 transition-opacity duration-300 ease-linear px-1 group-hover:opacity-100 focus:opacity-100 group-hover:grayscale-0 focus:grayscale-0" title="Permalink">
#
</a>
@endif
</{{ $tag }}>
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,6 @@ trait SetsUpMarkdownConverter
{
protected function enableDynamicExtensions(): void
{
if ($this->canEnablePermalinks()) {
$this->configurePermalinksExtension();
}

if ($this->canEnableTorchlight()) {
$this->addExtension(TorchlightExtension::class);
}
Expand Down
50 changes: 13 additions & 37 deletions packages/framework/src/Framework/Services/MarkdownService.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
use Hyde\Facades\Config;
use Hyde\Facades\Features;
use Hyde\Markdown\Models\MarkdownDocument;
use Hyde\Markdown\Processing\HeadingRenderer;
use Hyde\Framework\Concerns\Internal\SetsUpMarkdownConverter;
use Hyde\Pages\DocumentationPage;
use Hyde\Markdown\MarkdownConverter;
use Hyde\Markdown\Contracts\MarkdownPreProcessorContract as PreProcessor;
use Hyde\Markdown\Contracts\MarkdownPostProcessorContract as PostProcessor;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;

use function str_contains;
use function str_replace;
Expand Down Expand Up @@ -54,6 +55,9 @@ class MarkdownService
/** @var array<class-string<\Hyde\Markdown\Contracts\MarkdownPostProcessorContract>> */
protected array $postprocessors = [];

/** @var array<string> */
protected array $headingRegistry = [];

public function __construct(string $markdown, ?string $pageClass = null)
{
$this->pageClass = $pageClass;
Expand Down Expand Up @@ -87,6 +91,8 @@ protected function setupConverter(): void
$this->initializeExtension($extension);
}

$this->configureCustomHeadingRenderer();

$this->registerPreProcessors();
$this->registerPostProcessors();
}
Expand Down Expand Up @@ -141,13 +147,6 @@ public function addFeature(string $feature): static
return $this;
}

public function withPermalinks(): static
{
$this->addFeature('permalinks');

return $this;
}

public function isDocumentationPage(): bool
{
return isset($this->pageClass) && $this->pageClass === DocumentationPage::class;
Expand All @@ -166,19 +165,6 @@ public function canEnableTorchlight(): bool
Features::hasTorchlight();
}

public function canEnablePermalinks(): bool
{
if ($this->hasFeature('permalinks')) {
return true;
}

if ($this->isDocumentationPage() && DocumentationPage::hasTableOfContents()) {
return true;
}

return false;
}

public function hasFeature(string $feature): bool
{
return in_array($feature, $this->features);
Expand All @@ -200,22 +186,6 @@ protected function injectTorchlightAttribution(): string
));
}

protected function configurePermalinksExtension(): void
{
$this->addExtension(HeadingPermalinkExtension::class);

$this->config = array_merge([
'heading_permalink' => [
'id_prefix' => '',
'fragment_prefix' => '',
'symbol' => '',
'insert' => 'after',
'min_heading_level' => 2,
'aria_hidden' => false,
],
], $this->config);
}

protected function enableAllHtmlElements(): void
{
$this->addExtension(DisallowedRawHtmlExtension::class);
Expand Down Expand Up @@ -272,4 +242,10 @@ protected static function findLineContentPositions(array $lines): array

return [0, 0];
}

protected function configureCustomHeadingRenderer(): void
{
$environment = $this->converter->getEnvironment();
$environment->addRenderer(Heading::class, new HeadingRenderer($this->pageClass, $this->headingRegistry));
}
}
92 changes: 92 additions & 0 deletions packages/framework/src/Markdown/Processing/HeadingRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace Hyde\Markdown\Processing;

use Hyde\Pages\DocumentationPage;
use Illuminate\Support\Str;
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;

/**
* Renders a heading node, and supports built-in permalink generation.
*
* @see \League\CommonMark\Extension\CommonMark\Renderer\Block\HeadingRenderer
*/
class HeadingRenderer implements NodeRendererInterface
{
/** @var ?class-string<\Hyde\Pages\Concerns\HydePage> */
protected ?string $pageClass = null;

/** @var array<string> */
protected array $headingRegistry = [];

/** @param ?class-string<\Hyde\Pages\Concerns\HydePage> $pageClass */
public function __construct(string $pageClass = null, array &$headingRegistry = [])
{
$this->pageClass = $pageClass;
$this->headingRegistry = &$headingRegistry;
}

public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
{
if (! ($node instanceof Heading)) {
throw new \InvalidArgumentException('Incompatible node type: '.get_class($node));
}

$content = $childRenderer->renderNodes($node->children());

$rendered = view('hyde::components.markdown-heading', [
'level' => $node->getLevel(),
'slot' => $content,
'id' => $this->makeHeadingId($content),
'addPermalink' => $this->canAddPermalink($content, $node->getLevel()),
'extraAttributes' => $node->data->get('attributes'),
])->render();

return $this->postProcess($rendered);
}

/** @internal */
public function canAddPermalink(string $content, int $level): bool
{
return config('markdown.permalinks.enabled', true)
&& $level >= config('markdown.permalinks.min_level', 2)
&& $level <= config('markdown.permalinks.max_level', 6)
&& ! str_contains($content, 'class="heading-permalink"')
&& in_array($this->pageClass, config('markdown.permalinks.pages', [DocumentationPage::class]));
}

/** @internal */
public function postProcess(string $html): string
{
$html = str_replace('class=""', '', $html);
$html = preg_replace('/<h([1-6]) >/', '<h$1>', $html);

return implode('', array_map('trim', explode("\n", $html)));
}

protected function makeHeadingId(string $contents): string
{
$identifier = $this->ensureIdentifierIsUnique(Str::slug($contents));

$this->headingRegistry[] = $identifier;

return $identifier;
}

protected function ensureIdentifierIsUnique(string $slug): string
{
$identifier = $slug;
$suffix = 2;

while (in_array($identifier, $this->headingRegistry)) {
$identifier = $slug.'-'.$suffix++;
}

return $identifier;
}
}
Loading

0 comments on commit 62f5092

Please sign in to comment.