Skip to content
Open
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,29 @@ Example crontab entry (daily at 03:00):
0 3 * * * cd /path/to/app && php docs.php stats:cleanup
```

## Markdown alerts

GitHub-style alert blockquotes render as the existing `.c-callout` boxes:

```markdown
> [!NOTE]
> Extra detail for the reader.

> [!TIP]
> A shortcut or recommended approach.

> [!IMPORTANT]
> Something easy to miss.

> [!WARNING]
> Risk or breaking change.

> [!CAUTION]
> Stronger warning than WARNING.
```

Spaces inside the marker (`[! NOTE]`) are accepted. Tables and other CommonMark features are unchanged.

## Building assets

From the `public/template/` directory, first load the dependencies with `npm install`.
Expand Down
167 changes: 167 additions & 0 deletions src/Helpers/AlertCalloutFixer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

declare(strict_types=1);

namespace MODXDocs\Helpers;

use DOMDocument;
use DOMElement;
use DOMNode;
use Masterminds\HTML5;

/**
* Turns GitHub-style markdown alerts into existing .c-callout markup.
*
* Supported (optional spaces inside the marker):
* > [!NOTE]
* > [!TIP]
* > [!IMPORTANT]
* > [!WARNING]
* > [!CAUTION]
*
* @see https://github.com/modxorg/Docs/issues/40
* @see https://github.com/modxorg/DocsApp/issues/353
*/
class AlertCalloutFixer
{
private const TYPES = [
'NOTE' => [
'class' => 'c-callout--info',
'title' => 'Note',
],
'TIP' => [
'class' => 'c-callout--success',
'title' => 'Tip',
],
'IMPORTANT' => [
'class' => 'c-callout--warning',
'title' => 'Important',
],
'WARNING' => [
'class' => 'c-callout--alert',
'title' => 'Warning',
],
'CAUTION' => [
'class' => 'c-callout--alert',
'title' => 'Caution',
],
];

private HTML5 $htmlParser;

public function __construct(?HTML5 $htmlParser = null)
{
$this->htmlParser = $htmlParser ?? new HTML5();
}

public function fix(string $markup): string
{
if ($markup === '' || stripos($markup, '[!') === false) {
return $markup;
}

$partialID = uniqid('alert_fixer_', false);
$wrapped = sprintf("<body id='%s'>%s</body>", $partialID, $markup);
$domDocument = $this->htmlParser->loadHTML($wrapped);
$body = $domDocument->getElementById($partialID);
if (!$body instanceof DOMElement) {
return $markup;
}

$blockquotes = [];
foreach ($body->getElementsByTagName('blockquote') as $node) {
$blockquotes[] = $node;
}

foreach ($blockquotes as $blockquote) {
if (!$blockquote instanceof DOMElement || !$blockquote->parentNode) {
continue;
}

$type = $this->detectType($blockquote);
if ($type === null) {
continue;
}

$this->transformBlockquote($domDocument, $blockquote, $type);
}

return $this->htmlParser->saveHTML($body->childNodes);
}

private function detectType(DOMElement $blockquote): ?string
{
$text = ltrim($blockquote->textContent);
if (!preg_match('/^\[\s*!\s*(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\s*\]/i', $text, $matches)) {
return null;
}

return strtoupper($matches[1]);
}

private function transformBlockquote(DOMDocument $dom, DOMElement $blockquote, string $type): void
{
$config = self::TYPES[$type];

$callout = $dom->createElement('div');
$callout->setAttribute('class', 'c-callout ' . $config['class']);
$callout->setAttribute('role', 'note');

$title = $dom->createElement('strong');
$title->setAttribute('class', 'c-callout__title');
$title->textContent = $config['title'];
$callout->appendChild($title);

$this->stripMarkerFromChildren($blockquote, $type);

while ($blockquote->firstChild) {
$child = $blockquote->firstChild;
$blockquote->removeChild($child);
if ($this->isEmptyParagraph($child)) {
continue;
}
$callout->appendChild($child);
}

$blockquote->parentNode->replaceChild($callout, $blockquote);
}

private function stripMarkerFromChildren(DOMElement $blockquote, string $type): void
{
$pattern = '/^\[\s*!\s*' . preg_quote($type, '/') . '\s*\]\s*/i';

foreach ($blockquote->childNodes as $child) {
if (!$child instanceof DOMElement) {
continue;
}

if (strtolower($child->tagName) !== 'p') {
continue;
}

// Prefer editing the first text node so nested HTML stays intact.
foreach ($child->childNodes as $inline) {
if ($inline->nodeType === XML_TEXT_NODE) {
$inline->nodeValue = preg_replace($pattern, '', (string) $inline->nodeValue, 1);
break;
}
}

// Marker-only paragraph: leave empty for later skip.
if (preg_match($pattern, ltrim($child->textContent))) {
$child->nodeValue = '';
}

break;
}
}

private function isEmptyParagraph(DOMNode $node): bool
{
if (!$node instanceof DOMElement || strtolower($node->tagName) !== 'p') {
return false;
}

return trim($node->textContent) === '';
}
}
4 changes: 4 additions & 0 deletions src/Model/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use League\CommonMark\MarkdownConverter;
use League\CommonMark\Renderer\HtmlDecorator;
use MODXDocs\Exceptions\NotFoundException;
use MODXDocs\Helpers\AlertCalloutFixer;
use MODXDocs\Helpers\LinkRenderer;
use MODXDocs\Helpers\MarkupFixer;
use MODXDocs\Helpers\RelativeImageRenderer;
Expand Down Expand Up @@ -127,6 +128,9 @@ private function renderBody(): void
$content .= '<pre><code>' . htmlspecialchars($this->body) . '</code></pre>';
}

$alertFixer = new AlertCalloutFixer();
$content = $alertFixer->fix($content);

$fixer = new MarkupFixer();
$this->renderedBody = $fixer->fix($content);
$cache->set($key, $this->renderedBody, null, $hash);
Expand Down
89 changes: 89 additions & 0 deletions tests/Unit/AlertCalloutFixerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace Tests\Unit;

use MODXDocs\Helpers\AlertCalloutFixer;
use Tests\BaseTestCase;

class AlertCalloutFixerTest extends BaseTestCase
{
private AlertCalloutFixer $fixer;

protected function set_up(): void
{
parent::set_up();
$this->fixer = new AlertCalloutFixer();
}

public function testConvertsNoteAlert(): void
{
$html = <<<'HTML'
<blockquote>
<p>[!NOTE]
Hello <strong>bold</strong>.</p>
</blockquote>
<p>After</p>
HTML;

$out = $this->fixer->fix($html);

$this->assertStringContainsString('c-callout c-callout--info', $out);
$this->assertStringContainsString('c-callout__title', $out);
$this->assertStringContainsString('Note', $out);
$this->assertStringContainsString('<strong>bold</strong>', $out);
$this->assertStringNotContainsString('[!NOTE]', $out);
$this->assertStringNotContainsString('<blockquote>', $out);
$this->assertStringContainsString('<p>After</p>', $out);
}

public function testAcceptsSpacedMarkerFromIssue40(): void
{
$html = <<<'HTML'
<blockquote>
<p>[! WARNING]
Careful.</p>
</blockquote>
HTML;

$out = $this->fixer->fix($html);

$this->assertStringContainsString('c-callout--alert', $out);
$this->assertStringContainsString('Warning', $out);
$this->assertStringContainsString('Careful.', $out);
$this->assertStringNotContainsString('[! WARNING]', $out);
}

public function testTipWithSeparateMarkerParagraph(): void
{
$html = <<<'HTML'
<blockquote>
<p>[!TIP]</p>
<p>Multi
line tip.</p>
</blockquote>
HTML;

$out = $this->fixer->fix($html);

$this->assertStringContainsString('c-callout--success', $out);
$this->assertStringContainsString('Tip', $out);
$this->assertStringContainsString('Multi', $out);
$this->assertStringNotContainsString('[!TIP]', $out);
}

public function testLeavesOrdinaryBlockquotesAlone(): void
{
$html = '<blockquote><p>Just a quote</p></blockquote>';
$this->assertSame($html, $this->fixer->fix($html));
}

public function testMapsImportantToWarningCallout(): void
{
$html = '<blockquote><p>[!IMPORTANT] Read this.</p></blockquote>';
$out = $this->fixer->fix($html);
$this->assertStringContainsString('c-callout--warning', $out);
$this->assertStringContainsString('Important', $out);
}
}
Loading