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' +++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' +[! WARNING] +Careful.
+++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 = '[!TIP]
+Multi +line tip.
+'; + $this->assertSame($html, $this->fixer->fix($html)); + } + + public function testMapsImportantToWarningCallout(): void + { + $html = 'Just a quote
'; + $out = $this->fixer->fix($html); + $this->assertStringContainsString('c-callout--warning', $out); + $this->assertStringContainsString('Important', $out); + } +}[!IMPORTANT] Read this.