From caf10a5ae6d6e5525189c8543001c28c4648dd31 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Fri, 4 Sep 2026 09:41:16 +0600 Subject: [PATCH] feat: render GitHub-style markdown alerts as callouts Convert > [!NOTE|TIP|IMPORTANT|WARNING|CAUTION] blockquotes into existing .c-callout markup after CommonMark. Addresses DocsApp #353 and the visual-tag part of modxorg/Docs#40. Rebuilt on major-maintenance per review on #404. --- README.md | 23 ++++ src/Helpers/AlertCalloutFixer.php | 167 +++++++++++++++++++++++++++ src/Model/Page.php | 4 + tests/Unit/AlertCalloutFixerTest.php | 89 ++++++++++++++ 4 files changed, 283 insertions(+) create mode 100644 src/Helpers/AlertCalloutFixer.php create mode 100644 tests/Unit/AlertCalloutFixerTest.php diff --git a/README.md b/README.md index 43b29c5..bd84178 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/src/Helpers/AlertCalloutFixer.php b/src/Helpers/AlertCalloutFixer.php new file mode 100644 index 0000000..da640f8 --- /dev/null +++ b/src/Helpers/AlertCalloutFixer.php @@ -0,0 +1,167 @@ + [!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("%s", $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) === ''; + } +} diff --git a/src/Model/Page.php b/src/Model/Page.php index 7229ed8..ea8e7cb 100644 --- a/src/Model/Page.php +++ b/src/Model/Page.php @@ -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; @@ -127,6 +128,9 @@ private function renderBody(): void $content .= '
' . htmlspecialchars($this->body) . '
'; } + $alertFixer = new AlertCalloutFixer(); + $content = $alertFixer->fix($content); + $fixer = new MarkupFixer(); $this->renderedBody = $fixer->fix($content); $cache->set($key, $this->renderedBody, null, $hash); diff --git a/tests/Unit/AlertCalloutFixerTest.php b/tests/Unit/AlertCalloutFixerTest.php new file mode 100644 index 0000000..4f57e20 --- /dev/null +++ b/tests/Unit/AlertCalloutFixerTest.php @@ -0,0 +1,89 @@ +fixer = new AlertCalloutFixer(); + } + + public function testConvertsNoteAlert(): void + { + $html = <<<'HTML' +
+

[!NOTE] +Hello bold.

+
+

After

+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('bold', $out); + $this->assertStringNotContainsString('[!NOTE]', $out); + $this->assertStringNotContainsString('
', $out); + $this->assertStringContainsString('

After

', $out); + } + + public function testAcceptsSpacedMarkerFromIssue40(): void + { + $html = <<<'HTML' +
+

[! WARNING] +Careful.

+
+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' +
+

[!TIP]

+

Multi +line tip.

+
+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 = '

Just a quote

'; + $this->assertSame($html, $this->fixer->fix($html)); + } + + public function testMapsImportantToWarningCallout(): void + { + $html = '

[!IMPORTANT] Read this.

'; + $out = $this->fixer->fix($html); + $this->assertStringContainsString('c-callout--warning', $out); + $this->assertStringContainsString('Important', $out); + } +}