From a188a94adc1cdd9eb3102ad1ae84792c59bb0734 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 22:05:50 +0000 Subject: [PATCH 1/8] feat: add Instagram platform support Instagram has no native rich-text formatting in any of its surfaces, so the new renderer substitutes ASCII letters and digits with characters from Unicode's Mathematical Alphanumeric Symbols block (sans-serif bold for **bold**, sans-serif italic for *italic*, monospace for `code`, combining U+0336 for ~~strike~~). Headers, blockquotes, code blocks, tables, lists, links, and task lists all map to Instagram-paste-friendly output that survives copy/paste into captions, bios, comments, and DMs. A new UnicodeStyler helper encapsulates the codepoint mapping so it can be reused if other future platforms adopt the same approach. The MarkdownConverter::toInstagram() facade mirrors the existing toSlack/toWhatsApp shape; 2,200 (caption length) is documented as the typical maxLength. --- README.md | 69 ++++-- composer.json | 2 +- src/MarkdownConverter.php | 6 + src/Renderers/InstagramRenderer.php | 119 +++++++++ src/Support/UnicodeStyler.php | 91 +++++++ .../Unit/Renderers/InstagramRendererTest.php | 230 ++++++++++++++++++ tests/Unit/Support/UnicodeStylerTest.php | 71 ++++++ 7 files changed, 565 insertions(+), 23 deletions(-) create mode 100644 src/Renderers/InstagramRenderer.php create mode 100644 src/Support/UnicodeStyler.php create mode 100644 tests/Unit/Renderers/InstagramRendererTest.php create mode 100644 tests/Unit/Support/UnicodeStylerTest.php diff --git a/README.md b/README.md index f01d850..328fb05 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ |[![Tests](https://img.shields.io/github/actions/workflow/status/blockshiftnetwork/chat-markdown-converter/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/blockshiftnetwork/chat-markdown-converter/actions/workflows/run-tests.yml) |[![Total Downloads](https://img.shields.io/packagist/dt/blockshiftnetwork/chat-markdown-converter.svg?style=flat-square)](https://packagist.org/packages/blockshiftnetwork/chat-markdown-converter) -Convert AI-generated Markdown to WhatsApp, Telegram, Discord and Slack formats using an Intermediate Representation (IR). Perfect for converting LLM responses to chat-friendly formats. +Convert AI-generated Markdown to WhatsApp, Telegram, Discord, Slack and Instagram formats using an Intermediate Representation (IR). Perfect for converting LLM responses to chat-friendly formats. This PHP library transforms ChatGPT, Claude, GPT-5, and other AI model outputs into platform-specific markup. It handles code blocks, tables, lists, links, and rich text formatting while maintaining readability across all supported platforms. @@ -16,7 +16,7 @@ This PHP library transforms ChatGPT, Claude, GPT-5, and other AI model outputs i - Fluent API with chainable method calls - Clean architecture using the Intermediate Representation pattern for extensibility -- Comprehensive test coverage with 168 passing tests (Pest) +- Comprehensive test coverage with 200+ passing tests (Pest) - Platform-specific rendering optimized for each chat platform - Smart message chunking that splits text at safe breakpoints - Zero external dependencies, lightweight implementation @@ -24,7 +24,7 @@ This PHP library transforms ChatGPT, Claude, GPT-5, and other AI model outputs i ## Use Cases ### AI Chatbots & Virtual Assistants -Send formatted responses from OpenAI, Anthropic, or other LLM APIs directly to users via their preferred messaging platform. Ensure code blocks, tables, and lists render correctly across Telegram, WhatsApp, Discord, and Slack. +Send formatted responses from OpenAI, Anthropic, or other LLM APIs directly to users via their preferred messaging platform. Ensure code blocks, tables, and lists render correctly across Telegram, WhatsApp, Discord, Slack, and Instagram. ### Customer Support Automation Automate support workflows by converting AI-generated help articles and documentation into chat-friendly formats. Preserve formatting while delivering concise, readable responses in your customers' channels. @@ -33,7 +33,7 @@ Automate support workflows by converting AI-generated help articles and document Integrate with CI/CD pipelines, monitoring systems, or alerting platforms to send formatted logs, error messages, or status updates to team channels. Convert Markdown reports to platform-appropriate syntax automatically. ### Content Distribution Systems -Distribute newsletters, summaries, or generated content across multiple platforms simultaneously. Write once in Markdown and automatically convert to Telegram HTML, WhatsApp text, Discord markdown, or Slack mrkdwn. +Distribute newsletters, summaries, or generated content across multiple platforms simultaneously. Write once in Markdown and automatically convert to Telegram HTML, WhatsApp text, Discord markdown, Slack mrkdwn, or Instagram-ready Unicode captions. ### Educational Platforms Convert AI-generated tutorials, code examples, and learning materials into appropriate formats for students across different communication channels. Keep code snippets and syntax highlighting functional. @@ -61,7 +61,7 @@ composer require blockshiftnetwork/chat-markdown-converter ## Why Choose This Library? ### Platform-Aware Formatting -Unlike naive Markdown-to-text converters, this library understands each platform's unique limitations and formatting rules. Telegram uses HTML tags, WhatsApp uses asterisk-based formatting, Discord and Slack have their own markdown variants - we handle all these differences automatically. +Unlike naive Markdown-to-text converters, this library understands each platform's unique limitations and formatting rules. Telegram uses HTML tags, WhatsApp uses asterisk-based formatting, Discord and Slack have their own markdown variants, and Instagram requires Unicode Mathematical Alphanumeric Symbols since it has no native markup at all - we handle all these differences automatically. ### Intermediate Representation Architecture By parsing Markdown into an abstract IR first, we ensure consistent behavior across all platforms. This clean architecture makes it easy to add new platforms or customize rendering logic without modifying the core parser. @@ -74,7 +74,7 @@ Automatically converts complex Markdown features to platform-compatible formats: - Links transform to each platform's expected format ### Production-Ready Reliability -Comprehensive test coverage (168+ tests) ensures consistent behavior across edge cases. Handle special characters, nested formatting, mixed content types, and Unicode/emoji support with confidence. +Comprehensive test coverage (200+ tests) ensures consistent behavior across edge cases. Handle special characters, nested formatting, mixed content types, and Unicode/emoji support with confidence. ### Developer Experience Simple, intuitive API with fluent method chaining. Convert in one line with static methods or take full control with the flexible parser options. Zero learning curve for Markdown developers. @@ -120,6 +120,9 @@ $discord = MarkdownConverter::toDiscord($markdown); // Slack (mrkdwn format) $slack = MarkdownConverter::toSlack($markdown); + +// Instagram (Unicode-substituted plain text — captions, bios, comments) +$instagram = MarkdownConverter::toInstagram($markdown, maxLength: 2200); ``` ### Fluent API @@ -202,7 +205,7 @@ MarkdownConverter::parse($markdown)->withOptions([ - Tables: Auto-converted to bullet lists for non-table platforms - Message Chunking: Smart text splitting with safe breakpoints - Unicode Support: Full UTF-8 support including emojis -- Multiple Platforms: Telegram, WhatsApp, Discord, Slack +- Multiple Platforms: Telegram, WhatsApp, Discord, Slack, Instagram ### Platform-Specific Features @@ -257,6 +260,28 @@ MarkdownConverter::parse($markdown)->withOptions([ - Images: `` (without !) - Task Lists: `- [x]` and `- [ ]` (native support) +#### Instagram + +Instagram has no native rich-text formatting in any surface (captions, bios, comments, DMs, story overlays, Reels descriptions). The renderer substitutes ASCII letters and digits with characters from Unicode's Mathematical Alphanumeric Symbols block so the output renders with the expected visual weight when pasted into Instagram. + +- Bold: Sans-serif bold Unicode glyphs (e.g. `b` becomes `𝗯`) +- Italic: Sans-serif italic Unicode glyphs (digits remain plain — Unicode has no italic digits) +- Bold Italic: Sans-serif bold-italic Unicode glyphs +- Strikethrough: Combining long stroke overlay `U+0336` after each character +- Highlight: Sans-serif bold (Instagram has no highlight equivalent) +- Inline Code: Monospace Unicode glyphs (e.g. `c` becomes `𝚌`) +- Code Blocks: Monospace body wrapped above and below with a heavy horizontal rule (`━━━━━━━━━━━━━━━━`); triple-backticks paste verbatim on Instagram so they're not used +- Headers: Sans-serif bold (regardless of `#` level) +- Links: `text: url` (Instagram captions don't auto-link `http(s)://` URLs) +- Images: `alt: url` (the `!` prefix is stripped) +- Blockquotes: `❝ quote ❞` (typographic quotation marks) +- Horizontal Rule: `━━━━━━━━━━━━━━━━` (heavy box-drawing line) +- Task Lists: `✅` (completed) and `⬜` (pending) with emojis +- Bullet Lists: Leading `-` replaced with `•` for visual polish +- Tables: Converted to bullet points with bold headers (Instagram has no table support) + +> **Accessibility note:** Mathematical Alphanumeric Symbols are read as separate, decontextualized characters by screen readers (e.g. `𝗯𝗼𝗹𝗱` is announced as "Mathematical Sans-Serif Bold B, Mathematical Sans-Serif Bold O…"). When accessibility matters more than visual emphasis, post the original Markdown body without running it through the Instagram renderer. + ### Roadmap **Completed** @@ -275,17 +300,17 @@ MarkdownConverter::parse($markdown)->withOptions([ ## Platform Comparison -| Feature | Telegram | WhatsApp | Discord | Slack | -|---------|----------|----------|---------|-------| -| Format Type | HTML | Text | Markdown | mrkdwn | -| Bold | `` | `*text*` | `**text**` | `*text*` | -| Italic | `` | `_text_` | `*text*` | `_text_` | -| Code | `` | `` ` `` | `` ` `` | `` ` `` | -| Code Blocks | `
` | Triple backticks | Triple backticks | Triple backticks |
-| Strikethrough | `` | `~text~` | `~~text~~` | `~text~` |
-| Links | `` | `text: url` | `[text](url)` | `` |
-| Tables | Not supported | Not supported | Native support | Not supported |
-| Max Message Length | 4096 chars | 4096 chars | 2000 chars | 40000 chars |
+| Feature | Telegram | WhatsApp | Discord | Slack | Instagram |
+|---------|----------|----------|---------|-------|-----------|
+| Format Type | HTML | Text | Markdown | mrkdwn | Unicode plain text |
+| Bold | `` | `*text*` | `**text**` | `*text*` | Unicode sans-serif bold |
+| Italic | `` | `_text_` | `*text*` | `_text_` | Unicode sans-serif italic |
+| Code | `` | `` ` `` | `` ` `` | `` ` `` | Unicode monospace |
+| Code Blocks | `
` | Triple backticks | Triple backticks | Triple backticks | Monospace + `━` rules |
+| Strikethrough | `` | `~text~` | `~~text~~` | `~text~` | Combining `U+0336` |
+| Links | `` | `text: url` | `[text](url)` | `` | `text: url` |
+| Tables | Not supported | Not supported | Native support | Not supported | Not supported |
+| Max Message Length | 4096 chars | 4096 chars | 2000 chars | 40000 chars | 2200 chars (caption) |
 
 ## Testing
 
@@ -297,7 +322,7 @@ composer test
 composer test-coverage
 ```
 
-**Current Test Status**: 168 passed, 1 skipped
+**Current Test Status**: 206 passed, 1 skipped
 
 ## Architecture
 
@@ -312,7 +337,7 @@ Markdown → Parser → IR → Renderer → Platform-Specific Format
 - Parser: Converts Markdown to IR
 - HeaderParser: Detects and parses markdown headers (# ## ###)
 - Parsers: Specialized parsers for code blocks, tables, links, styles, blockquotes, horizontal rules
-- Renderers: Platform-specific renderers (Telegram, WhatsApp, Discord, Slack)
+- Renderers: Platform-specific renderers (Telegram, WhatsApp, Discord, Slack, Instagram)
 - Support: IR, TextChunker
 
 ## Contributing
@@ -346,7 +371,7 @@ The IR pattern provides better separation of concerns and extensibility. Parse o
 Yes! Extend the `AbstractRenderer` class to create custom renderers. The parser provides a structured IR that you can transform into any format you need.
 
 ### How does table conversion work?
-For platforms without native table support (Telegram, WhatsApp, Slack), tables are automatically converted to hierarchical bullet lists, preserving the structure and readability.
+For platforms without native table support (Telegram, WhatsApp, Slack, Instagram), tables are automatically converted to hierarchical bullet lists, preserving the structure and readability.
 
 ### Does this library support all Markdown features?
 We support the most common Markdown features used in AI responses: headings, code blocks, lists, links, images, blockquotes, horizontal rules, and text formatting. See the Supported Features section for details.
@@ -358,7 +383,7 @@ The `TextChunker` intelligently splits long messages at safe breakpoints (after
 This library requires PHP 8.3 or higher, taking advantage of modern PHP features like match expressions and readonly properties.
 
 ### Is this suitable for production use?
-Yes! The library has comprehensive test coverage (168+ tests) and is actively maintained. It's designed for performance and reliability in production environments.
+Yes! The library has comprehensive test coverage (200+ tests) and is actively maintained. It's designed for performance and reliability in production environments.
 
 ## Optimization Tips
 
diff --git a/composer.json b/composer.json
index d119b4a..2a0712c 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
 {
     "name": "blockshiftnetwork/chat-markdown-converter",
-    "description": "Convert AI-generated Markdown to WhatsApp, Telegram, Discord and Slack compatible formats using an Intermediate Representation (IR).",
+    "description": "Convert AI-generated Markdown to WhatsApp, Telegram, Discord, Slack and Instagram compatible formats using an Intermediate Representation (IR).",
     "keywords": [
         "blockshiftnetwork",
         "chat-markdown-converter"
diff --git a/src/MarkdownConverter.php b/src/MarkdownConverter.php
index 4ae1006..a21d6f0 100644
--- a/src/MarkdownConverter.php
+++ b/src/MarkdownConverter.php
@@ -4,6 +4,7 @@
 
 use Blockshift\ChatMarkdown\Renderers\Contracts\RendererContract;
 use Blockshift\ChatMarkdown\Renderers\DiscordRenderer;
+use Blockshift\ChatMarkdown\Renderers\InstagramRenderer;
 use Blockshift\ChatMarkdown\Renderers\SlackRenderer;
 use Blockshift\ChatMarkdown\Renderers\TelegramRenderer;
 use Blockshift\ChatMarkdown\Renderers\WhatsAppRenderer;
@@ -43,6 +44,11 @@ public static function toSlack(string $markdown, ?int $maxLength = null): string
         return self::parse($markdown)->using(new SlackRenderer)->render($maxLength);
     }
 
+    public static function toInstagram(string $markdown, ?int $maxLength = null): string|array
+    {
+        return self::parse($markdown)->using(new InstagramRenderer)->render($maxLength);
+    }
+
     private function __construct(
         private readonly string $markdown
     ) {}
diff --git a/src/Renderers/InstagramRenderer.php b/src/Renderers/InstagramRenderer.php
new file mode 100644
index 0000000..179153e
--- /dev/null
+++ b/src/Renderers/InstagramRenderer.php
@@ -0,0 +1,119 @@
+ $this->renderParagraph($block['content']),
+            'header' => $this->renderHeader($block['content'], $block['level'] ?? 1),
+            'code' => $this->renderCodeBlock($block['content'], $block['lang'] ?? null),
+            'table' => $this->renderTable($block),
+            'blockquote' => $this->renderBlockquote($block['content']),
+            'horizontal_rule' => $this->renderHorizontalRule(),
+            default => '',
+        };
+    }
+
+    protected function renderHeader(string $content, int $level): string
+    {
+        return UnicodeStyler::bold($content);
+    }
+
+    protected function renderParagraph(string $content): string
+    {
+        $content = preg_replace_callback(
+            '/__BOLDITALIC__(.+?)__BOLDITALIC__/',
+            fn ($m) => UnicodeStyler::boldItalic($m[1]),
+            $content
+        );
+
+        $content = preg_replace_callback(
+            '/__HIGHLIGHT__(.+?)__HIGHLIGHT__/',
+            fn ($m) => UnicodeStyler::bold($m[1]),
+            $content
+        );
+
+        $content = preg_replace_callback(
+            '/\*\*(.+?)\*\*/',
+            fn ($m) => UnicodeStyler::bold($m[1]),
+            $content
+        );
+
+        $content = preg_replace_callback(
+            '/(? UnicodeStyler::italic($m[1]),
+            $content
+        );
+
+        $content = preg_replace_callback(
+            '/~~(.+?)~~/',
+            fn ($m) => UnicodeStyler::strikethrough($m[1]),
+            $content
+        );
+
+        $content = preg_replace_callback(
+            '/`(.+?)`/',
+            fn ($m) => UnicodeStyler::monospace($m[1]),
+            $content
+        );
+
+        $content = preg_replace_callback(
+            '/(.+?) \((https?:\/\/[^\)]+)\)/',
+            fn ($m) => "{$m[1]}: {$m[2]}",
+            $content
+        );
+
+        $content = preg_replace('/!/', '', $content);
+
+        $content = preg_replace('/-\s+\[x\]\s*(.*)/', '✅ $1', $content);
+        $content = preg_replace('/-\s+\[\s\]\s*(.*)/', '⬜ $1', $content);
+
+        $content = preg_replace('/^-\s+/m', '• ', $content);
+
+        return $content;
+    }
+
+    protected function renderCodeBlock(string $content, ?string $lang = null): string
+    {
+        $lines = explode("\n", $content);
+        $monospace = implode("\n", array_map(fn ($line) => UnicodeStyler::monospace($line), $lines));
+
+        return self::HORIZONTAL_RULE."\n".$monospace."\n".self::HORIZONTAL_RULE;
+    }
+
+    protected function renderTable(array $data): string
+    {
+        $headers = $data['headers'] ?? [];
+        $rows = $data['rows'] ?? [];
+        $output = '';
+
+        foreach ($rows as $row) {
+            foreach ($row as $index => $cell) {
+                $header = $headers[$index] ?? '';
+                if ($header !== '' && $cell !== '') {
+                    $boldHeader = UnicodeStyler::bold($header);
+                    $output .= "• {$boldHeader}: {$cell}\n";
+                }
+            }
+        }
+
+        return trim($output);
+    }
+
+    protected function renderBlockquote(string $content): string
+    {
+        return "❝ {$content} ❞";
+    }
+
+    protected function renderHorizontalRule(): string
+    {
+        return self::HORIZONTAL_RULE;
+    }
+}
diff --git a/src/Support/UnicodeStyler.php b/src/Support/UnicodeStyler.php
new file mode 100644
index 0000000..43468ff
--- /dev/null
+++ b/src/Support/UnicodeStyler.php
@@ -0,0 +1,91 @@
+= 0x41 && $code <= 0x5A) {
+                $output .= mb_chr($upperBase + ($code - 0x41), 'UTF-8');
+            } elseif ($code >= 0x61 && $code <= 0x7A) {
+                $output .= mb_chr($lowerBase + ($code - 0x61), 'UTF-8');
+            } elseif ($digitBase !== null && $code >= 0x30 && $code <= 0x39) {
+                $output .= mb_chr($digitBase + ($code - 0x30), 'UTF-8');
+            } else {
+                $output .= $char;
+            }
+        }
+
+        return $output;
+    }
+}
diff --git a/tests/Unit/Renderers/InstagramRendererTest.php b/tests/Unit/Renderers/InstagramRendererTest.php
new file mode 100644
index 0000000..1f4bb9b
--- /dev/null
+++ b/tests/Unit/Renderers/InstagramRendererTest.php
@@ -0,0 +1,230 @@
+addBlock('paragraph', ['content' => 'Hello world']);
+
+    expect($renderer->render($ir))->toBe('Hello world');
+});
+
+it('renders bold text using sans-serif bold Unicode', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '**Bold** text']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::bold('Bold'));
+    expect($result)->not->toContain('**');
+});
+
+it('renders italic text using sans-serif italic Unicode', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '*italic* text']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::italic('italic'));
+});
+
+it('renders bold-italic marker using sans-serif bold-italic Unicode', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '__BOLDITALIC__nested__BOLDITALIC__']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::boldItalic('nested'));
+    expect($result)->not->toContain('__BOLDITALIC__');
+});
+
+it('renders strikethrough with combining stroke overlay', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '~~strike~~ text']);
+
+    $result = $renderer->render($ir);
+    $combining = mb_chr(0x0336, 'UTF-8');
+
+    expect($result)->toContain('s'.$combining.'t'.$combining.'r'.$combining);
+    expect($result)->not->toContain('~~');
+});
+
+it('renders inline code as monospace Unicode', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '`code` text']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::monospace('code'));
+    expect($result)->not->toContain('`');
+});
+
+it('renders code blocks wrapped in heavy horizontal rules with monospace body', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('code', ['content' => 'echo "test";', 'lang' => 'php']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('━━━━━━━━━━━━━━━━');
+    expect($result)->toContain(UnicodeStyler::monospace('echo'));
+    expect($result)->not->toContain('```');
+});
+
+it('renders links as text colon url', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => 'Site (https://example.com)']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('Site: https://example.com');
+});
+
+it('strips exclamation mark from image alt text', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '!Alt (https://example.com/img.png)']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('Alt: https://example.com/img.png');
+    expect($result)->not->toContain('!');
+});
+
+it('renders headers as sans-serif bold regardless of level', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()
+        ->addBlock('header', ['content' => 'Title', 'level' => 1])
+        ->addBlock('header', ['content' => 'Sub', 'level' => 3]);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::bold('Title'));
+    expect($result)->toContain(UnicodeStyler::bold('Sub'));
+});
+
+it('converts tables to bullet list with bold headers', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('table', [
+        'headers' => ['Name', 'Value'],
+        'rows' => [['Test', '123']],
+        'alignments' => ['left', 'left'],
+    ]);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('• '.UnicodeStyler::bold('Name').': Test');
+    expect($result)->toContain('• '.UnicodeStyler::bold('Value').': 123');
+});
+
+it('renders blockquote with typographic quotation marks', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('blockquote', ['content' => 'A quote']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('❝ A quote ❞');
+});
+
+it('renders horizontal rule as heavy box-drawing line', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('horizontal_rule', []);
+
+    expect($renderer->render($ir))->toBe('━━━━━━━━━━━━━━━━');
+});
+
+it('converts completed task lists to checkmark emoji', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '- [x] Done']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('✅');
+    expect($result)->toContain('Done');
+    expect($result)->not->toContain('[x]');
+});
+
+it('converts pending task lists to white square emoji', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '- [ ] Todo']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('⬜');
+    expect($result)->toContain('Todo');
+    expect($result)->not->toContain('[ ]');
+});
+
+it('renders highlight marker as bold Unicode', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '__HIGHLIGHT__important__HIGHLIGHT__ text']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::bold('important'));
+    expect($result)->not->toContain('__HIGHLIGHT__');
+});
+
+it('handles mixed formatting in single paragraph', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '**bold** *italic* `code` plain']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::bold('bold'));
+    expect($result)->toContain(UnicodeStyler::italic('italic'));
+    expect($result)->toContain(UnicodeStyler::monospace('code'));
+    expect($result)->toContain('plain');
+});
+
+it('preserves emoji and non-Latin characters in paragraphs', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '🚀 café 中文 日本語']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('🚀');
+    expect($result)->toContain('café');
+    expect($result)->toContain('中文');
+    expect($result)->toContain('日本語');
+});
+
+it('renders multiple paragraphs separated by blank lines', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()
+        ->addBlock('paragraph', ['content' => 'First'])
+        ->addBlock('paragraph', ['content' => 'Second']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('First');
+    expect($result)->toContain('Second');
+    expect($result)->toContain("\n\n");
+});
+
+it('replaces dash bullet markers with bullet character', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => "- one\n- two"]);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('• one');
+    expect($result)->toContain('• two');
+});
+
+it('exposes the toInstagram facade returning a string', function () {
+    $result = MarkdownConverter::toInstagram('**Bold** message');
+
+    expect($result)->toBeString();
+    expect($result)->toContain(UnicodeStyler::bold('Bold'));
+});
+
+it('returns array when chunking exceeds the caption limit', function () {
+    $longText = str_repeat('Lorem ipsum dolor sit amet. ', 100);
+
+    $result = MarkdownConverter::toInstagram($longText, 200);
+
+    expect($result)->toBeArray();
+    expect(count($result))->toBeGreaterThan(1);
+});
diff --git a/tests/Unit/Support/UnicodeStylerTest.php b/tests/Unit/Support/UnicodeStylerTest.php
new file mode 100644
index 0000000..d88daa1
--- /dev/null
+++ b/tests/Unit/Support/UnicodeStylerTest.php
@@ -0,0 +1,71 @@
+toBe('𝗵𝗲𝗹𝗹𝗼');
+});
+
+it('converts uppercase letters to sans-serif bold', function () {
+    expect(UnicodeStyler::bold('Hello'))->toBe('𝗛𝗲𝗹𝗹𝗼');
+});
+
+it('converts digits to sans-serif bold', function () {
+    expect(UnicodeStyler::bold('123'))->toBe('𝟭𝟮𝟯');
+});
+
+it('preserves spaces and punctuation in bold', function () {
+    expect(UnicodeStyler::bold('Hi, world!'))->toBe('𝗛𝗶, 𝘄𝗼𝗿𝗹𝗱!');
+});
+
+it('converts lowercase letters to sans-serif italic', function () {
+    expect(UnicodeStyler::italic('abc'))->toBe('𝘢𝘣𝘤');
+});
+
+it('converts uppercase letters to sans-serif italic', function () {
+    expect(UnicodeStyler::italic('ABC'))->toBe('𝘈𝘉𝘊');
+});
+
+it('leaves digits unchanged in italic (no italic digits in Unicode)', function () {
+    expect(UnicodeStyler::italic('a1b2'))->toBe('𝘢1𝘣2');
+});
+
+it('converts to sans-serif bold italic', function () {
+    expect(UnicodeStyler::boldItalic('hi'))->toBe('𝙝𝙞');
+});
+
+it('converts to monospace', function () {
+    expect(UnicodeStyler::monospace('php'))->toBe('𝚙𝚑𝚙');
+});
+
+it('converts digits to monospace', function () {
+    expect(UnicodeStyler::monospace('php8'))->toBe('𝚙𝚑𝚙𝟾');
+});
+
+it('adds combining stroke overlay for strikethrough', function () {
+    $combining = mb_chr(0x0336, 'UTF-8');
+    expect(UnicodeStyler::strikethrough('ab'))->toBe('a'.$combining.'b'.$combining);
+});
+
+it('does not add combining stroke to spaces', function () {
+    $combining = mb_chr(0x0336, 'UTF-8');
+    expect(UnicodeStyler::strikethrough('a b'))->toBe('a'.$combining.' b'.$combining);
+});
+
+it('preserves accented characters unchanged', function () {
+    expect(UnicodeStyler::bold('café'))->toBe('𝗰𝗮𝗳é');
+});
+
+it('preserves emoji unchanged', function () {
+    expect(UnicodeStyler::bold('🚀 go'))->toBe('🚀 𝗴𝗼');
+});
+
+it('preserves non-Latin scripts unchanged', function () {
+    expect(UnicodeStyler::bold('中文'))->toBe('中文');
+});
+
+it('returns empty string for empty input', function () {
+    expect(UnicodeStyler::bold(''))->toBe('');
+    expect(UnicodeStyler::italic(''))->toBe('');
+    expect(UnicodeStyler::strikethrough(''))->toBe('');
+});

From 4e8e345e8866c035b2933d7103889dd6a5667c5e Mon Sep 17 00:00:00 2001
From: AlexR1712 <8460736+AlexR1712@users.noreply.github.com>
Date: Tue, 5 May 2026 22:06:13 +0000
Subject: [PATCH 2/8] Fix styling

---
 tests/Feature/MarkdownConverterTest.php | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/tests/Feature/MarkdownConverterTest.php b/tests/Feature/MarkdownConverterTest.php
index 59d92a9..e23f971 100644
--- a/tests/Feature/MarkdownConverterTest.php
+++ b/tests/Feature/MarkdownConverterTest.php
@@ -1,6 +1,8 @@
 using(new Blockshift\ChatMarkdown\Renderers\TelegramRenderer)->render();
+    $result = MarkdownConverter::parse($markdown)->using(new TelegramRenderer)->render();
 
     expect($result)->toContain('Hello');
 });
 
 it('supports options', function () {
     $markdown = "| Header |\n| --- |\n| Content |";
-    $result = MarkdownConverter::parse($markdown)->using(new Blockshift\ChatMarkdown\Renderers\WhatsAppRenderer)->render();
+    $result = MarkdownConverter::parse($markdown)->using(new WhatsAppRenderer)->render();
 
     expect($result)->toContain('Header');
     expect($result)->toContain('Content');

From 14226872d0873b6a8a8238447334fbe20dbf3ca0 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 5 May 2026 22:42:58 +0000
Subject: [PATCH 3/8] docs: document 50-char chunk overshoot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

TextChunker may exceed maxLength by up to 50 chars to land on a safe
breakpoint instead of breaking mid-word. This was discovered the hard
way during Instagram-renderer verification — assertions of `<=
maxLength` failed by 6 chars on a 2,200-char caption test until the
overshoot was accounted for. Documents the contract in both the README
FAQ and the MAX_CHUNK_OVERSHOOT constant.
---
 README.md                   | 2 +-
 src/Support/TextChunker.php | 5 +++++
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 328fb05..8945865 100644
--- a/README.md
+++ b/README.md
@@ -377,7 +377,7 @@ For platforms without native table support (Telegram, WhatsApp, Slack, Instagram
 We support the most common Markdown features used in AI responses: headings, code blocks, lists, links, images, blockquotes, horizontal rules, and text formatting. See the Supported Features section for details.
 
 ### What about message length limits?
-The `TextChunker` intelligently splits long messages at safe breakpoints (after sentences, paragraphs, or list items) while preserving formatting and avoiding broken markup.
+The `TextChunker` intelligently splits long messages at safe breakpoints (after sentences, paragraphs, or list items) while preserving formatting and avoiding broken markup. To land on a clean breakpoint, a chunk may exceed the requested `maxLength` by up to 50 characters; budget for that overshoot when choosing your limit (e.g. pass `maxLength: 4046` if you absolutely must stay under Telegram's 4096-char hard cap).
 
 ### Can I use this with any PHP version?
 This library requires PHP 8.3 or higher, taking advantage of modern PHP features like match expressions and readonly properties.
diff --git a/src/Support/TextChunker.php b/src/Support/TextChunker.php
index cb8b403..15c4baa 100644
--- a/src/Support/TextChunker.php
+++ b/src/Support/TextChunker.php
@@ -6,6 +6,11 @@ final class TextChunker
 {
     private const SAFE_BREAKPOINTS = [' ', "\n", "\t", '.', ',', ';', ':', '-', '!', '?', ')', ']', '}', '"', "'"];
 
+    /**
+     * Maximum number of characters a chunk is allowed to exceed `maxLength` by
+     * in order to land on a safe breakpoint instead of mid-word. Callers that
+     * must respect a hard cap should pass `maxLength = hardCap - 50`.
+     */
     private const MAX_CHUNK_OVERSHOOT = 50;
 
     public static function chunk(string $text, int $maxLength, string $encoding = 'UTF-8'): array

From 40f22e9dd00f32e0d27d456ab0aa1006c19761c1 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 5 May 2026 22:44:14 +0000
Subject: [PATCH 4/8] fix: strip image '!' prefix at the parser instead of
 every renderer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Every renderer was running preg_replace('/!/', '', $content) to strip
the '!' that LinkParser::formatImage() prepended to image markdown.
That stripped legitimate exclamation marks from prose too — 'Hello!'
became 'Hello'. Pushing the strip up into LinkParser solves both:

  - LinkParser::formatImage() now emits 'alt (url)' (no leading '!').
  - Every renderer drops its 'preg_replace(/!/, ...)' line.
  - Image rendering is unchanged because no renderer differentiated
    images from links anyway — they all rendered both as the platform's
    native link form.

Adds two new feature tests covering both directions: '!' is stripped
from image markdown, and '!' survives in plain prose, on all five
platforms.
---
 src/Parsers/LinkParser.php                    |  2 +-
 src/Renderers/DiscordRenderer.php             |  2 --
 src/Renderers/InstagramRenderer.php           |  2 --
 src/Renderers/SlackRenderer.php               |  2 --
 src/Renderers/TelegramRenderer.php            |  2 --
 src/Renderers/WhatsAppRenderer.php            |  2 --
 tests/Feature/MarkdownConverterTest.php       | 20 +++++++++++++++++++
 .../Unit/Renderers/InstagramRendererTest.php  | 10 ++++++++--
 8 files changed, 29 insertions(+), 13 deletions(-)

diff --git a/src/Parsers/LinkParser.php b/src/Parsers/LinkParser.php
index 67b296a..daf1980 100644
--- a/src/Parsers/LinkParser.php
+++ b/src/Parsers/LinkParser.php
@@ -24,6 +24,6 @@ private function formatLink(string $text, string $url): string
 
     private function formatImage(string $alt, string $url): string
     {
-        return "!{$alt} ({$url})";
+        return "{$alt} ({$url})";
     }
 }
diff --git a/src/Renderers/DiscordRenderer.php b/src/Renderers/DiscordRenderer.php
index a96b354..a5037fc 100644
--- a/src/Renderers/DiscordRenderer.php
+++ b/src/Renderers/DiscordRenderer.php
@@ -40,8 +40,6 @@ protected function renderParagraph(string $content): string
 
         $content = preg_replace_callback('/__HIGHLIGHT__(.+?)__HIGHLIGHT__/', fn ($m) => "**{$m[1]}**", $content);
 
-        $content = preg_replace('/!/', '', $content);
-
         $content = $this->convertLinks($content);
 
         return $content;
diff --git a/src/Renderers/InstagramRenderer.php b/src/Renderers/InstagramRenderer.php
index 179153e..af1cb44 100644
--- a/src/Renderers/InstagramRenderer.php
+++ b/src/Renderers/InstagramRenderer.php
@@ -70,8 +70,6 @@ protected function renderParagraph(string $content): string
             $content
         );
 
-        $content = preg_replace('/!/', '', $content);
-
         $content = preg_replace('/-\s+\[x\]\s*(.*)/', '✅ $1', $content);
         $content = preg_replace('/-\s+\[\s\]\s*(.*)/', '⬜ $1', $content);
 
diff --git a/src/Renderers/SlackRenderer.php b/src/Renderers/SlackRenderer.php
index 156b8ef..17b4b93 100644
--- a/src/Renderers/SlackRenderer.php
+++ b/src/Renderers/SlackRenderer.php
@@ -37,8 +37,6 @@ protected function renderParagraph(string $content): string
 
         $content = preg_replace_callback('/__HIGHLIGHT__(.+?)__HIGHLIGHT__/', fn ($m) => "*{$m[1]}*", $content);
 
-        $content = preg_replace('/!/', '', $content);
-
         $content = $this->convertLinks($content);
         $content = preg_replace_callback('/~~(.+?)~~/', fn ($m) => "~{$m[1]}~", $content);
         $content = preg_replace_callback('/\*\*(.+?)\*\*/', fn ($m) => "*{$m[1]}*", $content);
diff --git a/src/Renderers/TelegramRenderer.php b/src/Renderers/TelegramRenderer.php
index fee78b7..abe525f 100644
--- a/src/Renderers/TelegramRenderer.php
+++ b/src/Renderers/TelegramRenderer.php
@@ -42,8 +42,6 @@ protected function renderParagraph(string $content): string
         $content = preg_replace_callback('/_(.+?)_/', fn ($m) => "{$m[1]}", $content);
         $content = preg_replace_callback('/`(.+?)`/', fn ($m) => "{$m[1]}", $content);
 
-        $content = preg_replace('/!/', '', $content);
-
         return $content;
     }
 
diff --git a/src/Renderers/WhatsAppRenderer.php b/src/Renderers/WhatsAppRenderer.php
index 2eb3a65..9d301b3 100644
--- a/src/Renderers/WhatsAppRenderer.php
+++ b/src/Renderers/WhatsAppRenderer.php
@@ -39,8 +39,6 @@ protected function renderParagraph(string $content): string
 
         $content = preg_replace_callback('/(.+?) \((https?:\/\/[^\)]+)\)/', fn ($matches) => "{$matches[1]}: {$matches[2]}", $content);
 
-        $content = preg_replace('/!/', '', $content);
-
         $content = preg_replace('/-\s+\[x\]\s*(.*)/', '✅ $1', $content);
         $content = preg_replace('/-\s+\[\s\]\s*(.*)/', '⬜ $1', $content);
 
diff --git a/tests/Feature/MarkdownConverterTest.php b/tests/Feature/MarkdownConverterTest.php
index e23f971..cb8eee5 100644
--- a/tests/Feature/MarkdownConverterTest.php
+++ b/tests/Feature/MarkdownConverterTest.php
@@ -131,6 +131,26 @@
     expect($result)->toContain('https://example.com/image.png');
 });
 
+it('preserves exclamation marks in plain prose for every platform', function () {
+    $markdown = 'Hello world! This is great!';
+
+    expect(MarkdownConverter::toTelegram($markdown))->toContain('Hello world!');
+    expect(MarkdownConverter::toWhatsApp($markdown))->toContain('Hello world!');
+    expect(MarkdownConverter::toDiscord($markdown))->toContain('Hello world!');
+    expect(MarkdownConverter::toSlack($markdown))->toContain('Hello world!');
+    expect(MarkdownConverter::toInstagram($markdown))->toContain('Hello world!');
+});
+
+it('strips exclamation mark from image markdown across platforms', function () {
+    $markdown = '![Alt](https://example.com/i.png)';
+
+    expect(MarkdownConverter::toTelegram($markdown))->not->toContain('!');
+    expect(MarkdownConverter::toWhatsApp($markdown))->not->toContain('!');
+    expect(MarkdownConverter::toDiscord($markdown))->not->toContain('!');
+    expect(MarkdownConverter::toSlack($markdown))->not->toContain('!');
+    expect(MarkdownConverter::toInstagram($markdown))->not->toContain('!');
+});
+
 it('handles escaped characters', function () {
     $markdown = '\\*not bold\\* and \\_not italic\\_';
     $result = MarkdownConverter::toTelegram($markdown);
diff --git a/tests/Unit/Renderers/InstagramRendererTest.php b/tests/Unit/Renderers/InstagramRendererTest.php
index 1f4bb9b..98cfbaa 100644
--- a/tests/Unit/Renderers/InstagramRendererTest.php
+++ b/tests/Unit/Renderers/InstagramRendererTest.php
@@ -82,9 +82,9 @@
     expect($result)->toContain('Site: https://example.com');
 });
 
-it('strips exclamation mark from image alt text', function () {
+it('renders parser-emitted image as plain link without exclamation mark', function () {
     $renderer = new InstagramRenderer;
-    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '!Alt (https://example.com/img.png)']);
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => 'Alt (https://example.com/img.png)']);
 
     $result = $renderer->render($ir);
 
@@ -92,6 +92,12 @@
     expect($result)->not->toContain('!');
 });
 
+it('preserves natural exclamation marks in prose', function () {
+    $result = MarkdownConverter::toInstagram('Hello world!');
+
+    expect($result)->toContain('Hello world!');
+});
+
 it('renders headers as sans-serif bold regardless of level', function () {
     $renderer = new InstagramRenderer;
     $ir = IntermediateRepresentation::empty()

From 97c373049cc27446d290a8dddf67c7954a3b2f46 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 5 May 2026 22:47:14 +0000
Subject: [PATCH 5/8] feat: process inline formatting inside blockquotes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Previously every renderer's renderBlockquote() returned its prefix
('> ', '💬 ', '❝ ❞') around raw block content, so '> use **bold**'
emerged as '> use **bold**' literally. Now blockquote content goes
through the same inline pipeline as paragraphs:

  - Parser applies LinkParser + StyleParser to blockquote content
    (matching what parseBuffer() already does for paragraphs).
  - Each renderer's renderBlockquote() pipes through renderParagraph()
    so bold/italic/strike/code/link transformations all apply.

Adds five per-platform tests covering bold/italic/strike/code inside
quotes plus a cross-platform link-in-quote test.

Note: a pre-existing non-greedy bug in convertLinks across all
renderers means "See [link](url)" still includes "See" in the link's
display text; this commit does not address that — it's tracked
separately and surfaces today regardless of blockquote handling.
---
 src/Parser.php                          | 23 +++++++++++--
 src/Renderers/DiscordRenderer.php       |  2 +-
 src/Renderers/InstagramRenderer.php     |  2 +-
 src/Renderers/SlackRenderer.php         |  2 +-
 src/Renderers/TelegramRenderer.php      |  4 +--
 src/Renderers/WhatsAppRenderer.php      |  2 +-
 tests/Feature/MarkdownConverterTest.php | 44 +++++++++++++++++++++++++
 7 files changed, 69 insertions(+), 10 deletions(-)

diff --git a/src/Parser.php b/src/Parser.php
index 9eb45eb..636db72 100644
--- a/src/Parser.php
+++ b/src/Parser.php
@@ -116,7 +116,7 @@ public function parse(string $markdown): IntermediateRepresentation
 
                 $blockquoteResult = $this->blockquoteParser->parseLine($line);
                 if ($blockquoteResult !== null) {
-                    $ir = $ir->addBlock('blockquote', $blockquoteResult);
+                    $ir = $ir->addBlock('blockquote', $this->processInlineForBlockquote($blockquoteResult));
                 }
 
                 continue;
@@ -125,7 +125,7 @@ public function parse(string $markdown): IntermediateRepresentation
             if ($this->blockquoteParser->isInBlockquote()) {
                 $blockquoteResult = $this->blockquoteParser->parseLine($line);
                 if ($blockquoteResult !== null) {
-                    $ir = $ir->addBlock('blockquote', $blockquoteResult);
+                    $ir = $ir->addBlock('blockquote', $this->processInlineForBlockquote($blockquoteResult));
                 }
 
                 continue;
@@ -174,7 +174,7 @@ public function parse(string $markdown): IntermediateRepresentation
         if ($this->blockquoteParser->isInBlockquote()) {
             $blockquoteResult = $this->blockquoteParser->finishBlockquote();
             if ($blockquoteResult !== null) {
-                $ir = $ir->addBlock('blockquote', $blockquoteResult);
+                $ir = $ir->addBlock('blockquote', $this->processInlineForBlockquote($blockquoteResult));
             }
             $this->blockquoteParser->reset();
         }
@@ -182,6 +182,23 @@ public function parse(string $markdown): IntermediateRepresentation
         return $ir;
     }
 
+    private function processInlineForBlockquote(array $blockquoteResult): array
+    {
+        $content = $blockquoteResult['content'] ?? '';
+
+        if ($this->options['parse_links']) {
+            $content = $this->linkParser->parse($content);
+        }
+
+        if ($this->options['parse_styles']) {
+            $content = $this->styleParser->parse($content);
+        }
+
+        $blockquoteResult['content'] = $content;
+
+        return $blockquoteResult;
+    }
+
     public function withOptions(array $options): self
     {
         $this->options = array_merge($this->options, $options);
diff --git a/src/Renderers/DiscordRenderer.php b/src/Renderers/DiscordRenderer.php
index a5037fc..8bed8b1 100644
--- a/src/Renderers/DiscordRenderer.php
+++ b/src/Renderers/DiscordRenderer.php
@@ -79,7 +79,7 @@ protected function renderTable(array $data): string
 
     protected function renderBlockquote(string $content): string
     {
-        return "> {$content}";
+        return '> '.$this->renderParagraph($content);
     }
 
     protected function renderHorizontalRule(): string
diff --git a/src/Renderers/InstagramRenderer.php b/src/Renderers/InstagramRenderer.php
index af1cb44..84a88d2 100644
--- a/src/Renderers/InstagramRenderer.php
+++ b/src/Renderers/InstagramRenderer.php
@@ -107,7 +107,7 @@ protected function renderTable(array $data): string
 
     protected function renderBlockquote(string $content): string
     {
-        return "❝ {$content} ❞";
+        return '❝ '.$this->renderParagraph($content).' ❞';
     }
 
     protected function renderHorizontalRule(): string
diff --git a/src/Renderers/SlackRenderer.php b/src/Renderers/SlackRenderer.php
index 17b4b93..fe9b104 100644
--- a/src/Renderers/SlackRenderer.php
+++ b/src/Renderers/SlackRenderer.php
@@ -79,7 +79,7 @@ protected function renderTable(array $data): string
 
     protected function renderBlockquote(string $content): string
     {
-        return "> {$content}";
+        return '> '.$this->renderParagraph($content);
     }
 
     protected function renderHorizontalRule(): string
diff --git a/src/Renderers/TelegramRenderer.php b/src/Renderers/TelegramRenderer.php
index abe525f..af3207b 100644
--- a/src/Renderers/TelegramRenderer.php
+++ b/src/Renderers/TelegramRenderer.php
@@ -79,9 +79,7 @@ protected function renderTable(array $data): string
 
     protected function renderBlockquote(string $content): string
     {
-        $content = $this->escapeText($content);
-
-        return "💬 {$content}";
+        return '💬 '.$this->renderParagraph($content);
     }
 
     protected function renderHorizontalRule(): string
diff --git a/src/Renderers/WhatsAppRenderer.php b/src/Renderers/WhatsAppRenderer.php
index 9d301b3..e298ed5 100644
--- a/src/Renderers/WhatsAppRenderer.php
+++ b/src/Renderers/WhatsAppRenderer.php
@@ -70,7 +70,7 @@ protected function renderTable(array $data): string
 
     protected function renderBlockquote(string $content): string
     {
-        return "💬 {$content}";
+        return '💬 '.$this->renderParagraph($content);
     }
 
     protected function renderHorizontalRule(): string
diff --git a/tests/Feature/MarkdownConverterTest.php b/tests/Feature/MarkdownConverterTest.php
index cb8eee5..f837eea 100644
--- a/tests/Feature/MarkdownConverterTest.php
+++ b/tests/Feature/MarkdownConverterTest.php
@@ -151,6 +151,50 @@
     expect(MarkdownConverter::toInstagram($markdown))->not->toContain('!');
 });
 
+it('processes inline formatting inside blockquotes for Telegram (HTML)', function () {
+    $result = MarkdownConverter::toTelegram('> Use **bold** and *italic* inside quotes');
+
+    expect($result)->toContain('bold');
+    expect($result)->toContain('italic');
+});
+
+it('processes inline formatting inside blockquotes for WhatsApp', function () {
+    $result = MarkdownConverter::toWhatsApp('> Use **bold** and *italic* inside quotes');
+
+    expect($result)->toContain('*bold*');
+    expect($result)->toContain('_italic_');
+});
+
+it('processes inline formatting inside blockquotes for Discord', function () {
+    $result = MarkdownConverter::toDiscord('> Use **bold** and `code` inside quotes');
+
+    expect($result)->toContain('**bold**');
+    expect($result)->toContain('`code`');
+});
+
+it('processes inline formatting inside blockquotes for Slack', function () {
+    $result = MarkdownConverter::toSlack('> Use **bold** and ~~strike~~ inside quotes');
+
+    expect($result)->toContain('*bold*');
+    expect($result)->toContain('~strike~');
+});
+
+it('converts links inside blockquotes for every platform', function () {
+    $markdown = '> [link](https://example.com)';
+
+    expect(MarkdownConverter::toTelegram($markdown))->toContain('link');
+    expect(MarkdownConverter::toWhatsApp($markdown))->toContain('link: https://example.com');
+    expect(MarkdownConverter::toDiscord($markdown))->toContain('[link](https://example.com)');
+    expect(MarkdownConverter::toSlack($markdown))->toContain('');
+    expect(MarkdownConverter::toInstagram($markdown))->toContain('link: https://example.com');
+});
+
+it('processes inline formatting inside blockquotes for Instagram', function () {
+    $result = MarkdownConverter::toInstagram('> Quoted **wow**');
+
+    expect($result)->toContain(\Blockshift\ChatMarkdown\Support\UnicodeStyler::bold('wow'));
+});
+
 it('handles escaped characters', function () {
     $markdown = '\\*not bold\\* and \\_not italic\\_';
     $result = MarkdownConverter::toTelegram($markdown);

From 6daa3a1b2ce535b5cb7aaee9689a9793ffd092e6 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 5 May 2026 22:48:19 +0000
Subject: [PATCH 6/8] test: add cross-platform snapshot fixtures

Pins the rendered output of a single representative LLM-style Markdown
document (headers, bold, italic, strikethrough, highlight, inline
code, code block, table, blockquote, horizontal rule, task list,
bullet list, link, image, emoji, plain prose with !) for every
platform. Any cross-cutting refactor that changes any renderer's
output will produce a byte-level diff here, making intentional changes
explicit and accidental changes loud.

Snapshots live in tests/Fixtures/ and are regeneratable from the doc
header in SnapshotTest.php.

These snapshots intentionally capture two pre-existing rendering
quirks I noticed during verification (link prefix swallowing in
convertLinks; adjacent-link merging in Telegram) so they're locked in
and visible. Fixing either of those will deliberately update the
relevant snapshot and tell us exactly what changed.
---
 tests/Feature/SnapshotTest.php            | 46 +++++++++++++++++++++++
 tests/Fixtures/llm-document.discord.txt   | 43 +++++++++++++++++++++
 tests/Fixtures/llm-document.instagram.txt | 43 +++++++++++++++++++++
 tests/Fixtures/llm-document.md            | 41 ++++++++++++++++++++
 tests/Fixtures/llm-document.slack.txt     | 43 +++++++++++++++++++++
 tests/Fixtures/llm-document.telegram.txt  | 41 ++++++++++++++++++++
 tests/Fixtures/llm-document.whatsapp.txt  | 43 +++++++++++++++++++++
 7 files changed, 300 insertions(+)
 create mode 100644 tests/Feature/SnapshotTest.php
 create mode 100644 tests/Fixtures/llm-document.discord.txt
 create mode 100644 tests/Fixtures/llm-document.instagram.txt
 create mode 100644 tests/Fixtures/llm-document.md
 create mode 100644 tests/Fixtures/llm-document.slack.txt
 create mode 100644 tests/Fixtures/llm-document.telegram.txt
 create mode 100644 tests/Fixtures/llm-document.whatsapp.txt

diff --git a/tests/Feature/SnapshotTest.php b/tests/Feature/SnapshotTest.php
new file mode 100644
index 0000000..cb54a73
--- /dev/null
+++ b/tests/Feature/SnapshotTest.php
@@ -0,0 +1,46 @@
+.txt", \
+ *             MarkdownConverter::to($md));'
+ */
+function fixture(string $name): string
+{
+    return file_get_contents(__DIR__.'/../Fixtures/'.$name);
+}
+
+it('renders the LLM document snapshot for Telegram', function () {
+    expect(MarkdownConverter::toTelegram(fixture('llm-document.md')))
+        ->toBe(fixture('llm-document.telegram.txt'));
+});
+
+it('renders the LLM document snapshot for WhatsApp', function () {
+    expect(MarkdownConverter::toWhatsApp(fixture('llm-document.md')))
+        ->toBe(fixture('llm-document.whatsapp.txt'));
+});
+
+it('renders the LLM document snapshot for Discord', function () {
+    expect(MarkdownConverter::toDiscord(fixture('llm-document.md')))
+        ->toBe(fixture('llm-document.discord.txt'));
+});
+
+it('renders the LLM document snapshot for Slack', function () {
+    expect(MarkdownConverter::toSlack(fixture('llm-document.md')))
+        ->toBe(fixture('llm-document.slack.txt'));
+});
+
+it('renders the LLM document snapshot for Instagram', function () {
+    expect(MarkdownConverter::toInstagram(fixture('llm-document.md')))
+        ->toBe(fixture('llm-document.instagram.txt'));
+});
diff --git a/tests/Fixtures/llm-document.discord.txt b/tests/Fixtures/llm-document.discord.txt
new file mode 100644
index 0000000..26b6393
--- /dev/null
+++ b/tests/Fixtures/llm-document.discord.txt
@@ -0,0 +1,43 @@
+**Project Status: Q2 2026**
+
+**Overview**
+
+We just shipped the **payments rewrite** and *cut p99 latency* by **58%**. ***Huge win!*** Backend codebase down ~~12,500~~ to 9,800 LOC.
+
+**Highlights**
+
+- Migrated to event-sourced ledger
+- Replaced `legacy/billing.php` with stateless service
+- 99.97% uptime over the rolling 30 days
+
+**Tasks for next sprint**
+
+- [x] Land observability dashboards
+- [x] Cut over EU region
+- [ ] Backfill Q1 reconciliations
+- [ ] Author postmortem for the March incident
+
+**Performance**
+
+• **Metric**: p50
+• **Before**: 180ms
+• **After**: 42ms
+• **Metric**: p99
+• **Before**: 2400ms
+• **After**: 1010ms
+
+**Code sample**
+
+```php
+function settle(Invoice $invoice): Receipt
+{
+    $ledger->record($invoice);
+    return Receipt::for($invoice);
+}
+```
+
+> Reliability is not a feature — it's a *prerequisite*.
+
+---
+
+[Read the full report at the wiki](https://wiki.example.com/q2-2026)[ and see the dashboard dashboard](https://img.example.com/dash.png).
\ No newline at end of file
diff --git a/tests/Fixtures/llm-document.instagram.txt b/tests/Fixtures/llm-document.instagram.txt
new file mode 100644
index 0000000..d7c6ab5
--- /dev/null
+++ b/tests/Fixtures/llm-document.instagram.txt
@@ -0,0 +1,43 @@
+𝗣𝗿𝗼𝗷𝗲𝗰𝘁 𝗦𝘁𝗮𝘁𝘂𝘀: 𝗤𝟮 𝟮𝟬𝟮𝟲
+
+𝗢𝘃𝗲𝗿𝘃𝗶𝗲𝘄
+
+We just shipped the 𝗽𝗮𝘆𝗺𝗲𝗻𝘁𝘀 𝗿𝗲𝘄𝗿𝗶𝘁𝗲 and 𝘤𝘶𝘵 𝘱99 𝘭𝘢𝘵𝘦𝘯𝘤𝘺 by 𝟱𝟴%. 𝙃𝙪𝙜𝙚 𝙬𝙞𝙣! Backend codebase down 1̶2̶,̶5̶0̶0̶ to 9,800 LOC.
+
+𝗛𝗶𝗴𝗵𝗹𝗶𝗴𝗵𝘁𝘀
+
+• Migrated to event-sourced ledger
+• Replaced 𝚕𝚎𝚐𝚊𝚌𝚢/𝚋𝚒𝚕𝚕𝚒𝚗𝚐.𝚙𝚑𝚙 with stateless service
+• 99.97% uptime over the rolling 30 days
+
+𝗧𝗮𝘀𝗸𝘀 𝗳𝗼𝗿 𝗻𝗲𝘅𝘁 𝘀𝗽𝗿𝗶𝗻𝘁
+
+✅ Land observability dashboards
+✅ Cut over EU region
+⬜ Backfill Q1 reconciliations
+⬜ Author postmortem for the March incident
+
+𝗣𝗲𝗿𝗳𝗼𝗿𝗺𝗮𝗻𝗰𝗲
+
+• 𝗠𝗲𝘁𝗿𝗶𝗰: p50
+• 𝗕𝗲𝗳𝗼𝗿𝗲: 180ms
+• 𝗔𝗳𝘁𝗲𝗿: 42ms
+• 𝗠𝗲𝘁𝗿𝗶𝗰: p99
+• 𝗕𝗲𝗳𝗼𝗿𝗲: 2400ms
+• 𝗔𝗳𝘁𝗲𝗿: 1010ms
+
+𝗖𝗼𝗱𝗲 𝘀𝗮𝗺𝗽𝗹𝗲
+
+━━━━━━━━━━━━━━━━
+𝚏𝚞𝚗𝚌𝚝𝚒𝚘𝚗 𝚜𝚎𝚝𝚝𝚕𝚎(𝙸𝚗𝚟𝚘𝚒𝚌𝚎 $𝚒𝚗𝚟𝚘𝚒𝚌𝚎): 𝚁𝚎𝚌𝚎𝚒𝚙𝚝
+{
+    $𝚕𝚎𝚍𝚐𝚎𝚛->𝚛𝚎𝚌𝚘𝚛𝚍($𝚒𝚗𝚟𝚘𝚒𝚌𝚎);
+    𝚛𝚎𝚝𝚞𝚛𝚗 𝚁𝚎𝚌𝚎𝚒𝚙𝚝::𝚏𝚘𝚛($𝚒𝚗𝚟𝚘𝚒𝚌𝚎);
+}
+━━━━━━━━━━━━━━━━
+
+❝ Reliability is not a feature — it's a 𝘱𝘳𝘦𝘳𝘦𝘲𝘶𝘪𝘴𝘪𝘵𝘦. ❞
+
+━━━━━━━━━━━━━━━━
+
+Read the full report at the wiki: https://wiki.example.com/q2-2026 and see the dashboard dashboard: https://img.example.com/dash.png.
\ No newline at end of file
diff --git a/tests/Fixtures/llm-document.md b/tests/Fixtures/llm-document.md
new file mode 100644
index 0000000..063a111
--- /dev/null
+++ b/tests/Fixtures/llm-document.md
@@ -0,0 +1,41 @@
+# Project Status: Q2 2026
+
+## Overview
+
+We just shipped the **payments rewrite** and *cut p99 latency* by ==58%==. ***Huge win!*** Backend codebase down ~~12,500~~ to 9,800 LOC.
+
+## Highlights
+
+- Migrated to event-sourced ledger
+- Replaced `legacy/billing.php` with stateless service
+- 99.97% uptime over the rolling 30 days
+
+## Tasks for next sprint
+
+- [x] Land observability dashboards
+- [x] Cut over EU region
+- [ ] Backfill Q1 reconciliations
+- [ ] Author postmortem for the March incident
+
+## Performance
+
+| Metric | Before | After |
+| --- | --- | --- |
+| p50 | 180ms | 42ms |
+| p99 | 2400ms | 1010ms |
+
+## Code sample
+
+```php
+function settle(Invoice $invoice): Receipt
+{
+    $ledger->record($invoice);
+    return Receipt::for($invoice);
+}
+```
+
+> Reliability is not a feature — it's a *prerequisite*.
+
+---
+
+Read the full report at [the wiki](https://wiki.example.com/q2-2026) and see the dashboard ![dashboard](https://img.example.com/dash.png).
diff --git a/tests/Fixtures/llm-document.slack.txt b/tests/Fixtures/llm-document.slack.txt
new file mode 100644
index 0000000..c93738a
--- /dev/null
+++ b/tests/Fixtures/llm-document.slack.txt
@@ -0,0 +1,43 @@
+*Project Status: Q2 2026*
+
+*Overview*
+
+We just shipped the *payments rewrite* and *cut p99 latency* by *58%*. *_Huge win!_* Backend codebase down ~12,500~ to 9,800 LOC.
+
+*Highlights*
+
+- Migrated to event-sourced ledger
+- Replaced `legacy/billing.php` with stateless service
+- 99.97% uptime over the rolling 30 days
+
+*Tasks for next sprint*
+
+- [x] Land observability dashboards
+- [x] Cut over EU region
+- [ ] Backfill Q1 reconciliations
+- [ ] Author postmortem for the March incident
+
+*Performance*
+
+• *Metric*: p50
+• *Before*: 180ms
+• *After*: 42ms
+• *Metric*: p99
+• *Before*: 2400ms
+• *After*: 1010ms
+
+*Code sample*
+
+```php
+function settle(Invoice $invoice): Receipt
+{
+    $ledger->record($invoice);
+    return Receipt::for($invoice);
+}
+```
+
+> Reliability is not a feature — it's a *prerequisite*.
+
+---
+
+.
\ No newline at end of file
diff --git a/tests/Fixtures/llm-document.telegram.txt b/tests/Fixtures/llm-document.telegram.txt
new file mode 100644
index 0000000..ef46d88
--- /dev/null
+++ b/tests/Fixtures/llm-document.telegram.txt
@@ -0,0 +1,41 @@
+Project Status: Q2 2026
+
+Overview
+
+We just shipped the payments rewrite and cut p99 latency by 58%. Huge win! Backend codebase down 12,500 to 9,800 LOC.
+
+Highlights
+
+- Migrated to event-sourced ledger
+- Replaced legacy/billing.php with stateless service
+- 99.97% uptime over the rolling 30 days
+
+Tasks for next sprint
+
+- [x] Land observability dashboards
+- [x] Cut over EU region
+- [ ] Backfill Q1 reconciliations
+- [ ] Author postmortem for the March incident
+
+Performance
+
+• Metric: p50
+• Before: 180ms
+• After: 42ms
+• Metric: p99
+• Before: 2400ms
+• After: 1010ms
+
+Code sample
+
+
function settle(Invoice $invoice): Receipt
+{
+    $ledger->record($invoice);
+    return Receipt::for($invoice);
+}
+ +💬 Reliability is not a feature — it's a prerequisite. + +--- + +Read the full report at the wiki and see the dashboard dashboard. \ No newline at end of file diff --git a/tests/Fixtures/llm-document.whatsapp.txt b/tests/Fixtures/llm-document.whatsapp.txt new file mode 100644 index 0000000..3a9f05e --- /dev/null +++ b/tests/Fixtures/llm-document.whatsapp.txt @@ -0,0 +1,43 @@ +*Project Status: Q2 2026* + +*Overview* + +We just shipped the *payments rewrite* and _cut p99 latency_ by *58%*. _*Huge win!*_ Backend codebase down ~12,500~ to 9,800 LOC. + +*Highlights* + +- Migrated to event-sourced ledger +- Replaced `legacy/billing.php` with stateless service +- 99.97% uptime over the rolling 30 days + +*Tasks for next sprint* + +✅ Land observability dashboards +✅ Cut over EU region +⬜ Backfill Q1 reconciliations +⬜ Author postmortem for the March incident + +*Performance* + +• Metric: p50 +• Before: 180ms +• After: 42ms +• Metric: p99 +• Before: 2400ms +• After: 1010ms + +*Code sample* + +``` +function settle(Invoice $invoice): Receipt +{ + $ledger->record($invoice); + return Receipt::for($invoice); +} +``` + +💬 Reliability is not a feature — it's a _prerequisite_. + +--- + +Read the full report at the wiki: https://wiki.example.com/q2-2026 and see the dashboard dashboard: https://img.example.com/dash.png. \ No newline at end of file From 84335c760c35545351d7e31e2fd23c40498383d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 22:50:02 +0000 Subject: [PATCH 7/8] refactor: lift renderBlock match into AbstractRenderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same renderBlock() match expression was duplicated verbatim across five concrete renderers (Telegram, WhatsApp, Discord, Slack, Instagram). Lifts it into AbstractRenderer along with sensible default implementations of every leaf method (renderParagraph, renderHeader, renderCodeBlock, renderTable, renderBlockquote, renderHorizontalRule). Each concrete renderer now overrides only the leaf methods it cares about — adding a new platform means writing formatting rules, not boilerplate. Net deletion: ~60 lines of duplicated dispatch. The new snapshot tests in tests/Feature/SnapshotTest.php confirm byte-identical output for every platform, so the refactor is verifiably behavior-preserving. The README's "Custom Renderer" example (which overrides renderBlock directly) still works because renderBlock is concrete-with-default rather than final, and the leaf methods now have non-throwing default implementations. --- src/Renderers/AbstractRenderer.php | 43 ++++++++++++++++++++++++++++- src/Renderers/DiscordRenderer.php | 13 --------- src/Renderers/InstagramRenderer.php | 13 --------- src/Renderers/SlackRenderer.php | 13 --------- src/Renderers/TelegramRenderer.php | 13 --------- src/Renderers/WhatsAppRenderer.php | 13 --------- 6 files changed, 42 insertions(+), 66 deletions(-) diff --git a/src/Renderers/AbstractRenderer.php b/src/Renderers/AbstractRenderer.php index 809c26e..5bb08cf 100644 --- a/src/Renderers/AbstractRenderer.php +++ b/src/Renderers/AbstractRenderer.php @@ -25,7 +25,48 @@ public function render(IntermediateRepresentation $ir, ?int $maxLength = null): return $output; } - abstract protected function renderBlock(array $block): string; + protected function renderBlock(array $block): string + { + return match ($block['type']) { + 'paragraph' => $this->renderParagraph($block['content']), + 'header' => $this->renderHeader($block['content'], $block['level'] ?? 1), + 'code' => $this->renderCodeBlock($block['content'], $block['lang'] ?? null), + 'table' => $this->renderTable($block), + 'blockquote' => $this->renderBlockquote($block['content']), + 'horizontal_rule' => $this->renderHorizontalRule(), + default => '', + }; + } + + protected function renderParagraph(string $content): string + { + return $content; + } + + protected function renderHeader(string $content, int $level): string + { + return $content; + } + + protected function renderCodeBlock(string $content, ?string $lang = null): string + { + return $content; + } + + protected function renderTable(array $data): string + { + return ''; + } + + protected function renderBlockquote(string $content): string + { + return "> {$content}"; + } + + protected function renderHorizontalRule(): string + { + return '---'; + } protected function escapeText(string $text): string { diff --git a/src/Renderers/DiscordRenderer.php b/src/Renderers/DiscordRenderer.php index 8bed8b1..9bda759 100644 --- a/src/Renderers/DiscordRenderer.php +++ b/src/Renderers/DiscordRenderer.php @@ -4,19 +4,6 @@ class DiscordRenderer extends AbstractRenderer { - protected function renderBlock(array $block): string - { - return match ($block['type']) { - 'paragraph' => $this->renderParagraph($block['content']), - 'header' => $this->renderHeader($block['content'], $block['level'] ?? 1), - 'code' => $this->renderCodeBlock($block['content'], $block['lang'] ?? null), - 'table' => $this->renderTable($block), - 'blockquote' => $this->renderBlockquote($block['content']), - 'horizontal_rule' => $this->renderHorizontalRule(), - default => '', - }; - } - protected function escapeText(string $text): string { $text = str_replace('\\', '\\\\', $text); diff --git a/src/Renderers/InstagramRenderer.php b/src/Renderers/InstagramRenderer.php index 84a88d2..7716c75 100644 --- a/src/Renderers/InstagramRenderer.php +++ b/src/Renderers/InstagramRenderer.php @@ -8,19 +8,6 @@ class InstagramRenderer extends AbstractRenderer { private const HORIZONTAL_RULE = '━━━━━━━━━━━━━━━━'; - protected function renderBlock(array $block): string - { - return match ($block['type']) { - 'paragraph' => $this->renderParagraph($block['content']), - 'header' => $this->renderHeader($block['content'], $block['level'] ?? 1), - 'code' => $this->renderCodeBlock($block['content'], $block['lang'] ?? null), - 'table' => $this->renderTable($block), - 'blockquote' => $this->renderBlockquote($block['content']), - 'horizontal_rule' => $this->renderHorizontalRule(), - default => '', - }; - } - protected function renderHeader(string $content, int $level): string { return UnicodeStyler::bold($content); diff --git a/src/Renderers/SlackRenderer.php b/src/Renderers/SlackRenderer.php index fe9b104..4092bcf 100644 --- a/src/Renderers/SlackRenderer.php +++ b/src/Renderers/SlackRenderer.php @@ -4,19 +4,6 @@ class SlackRenderer extends AbstractRenderer { - protected function renderBlock(array $block): string - { - return match ($block['type']) { - 'paragraph' => $this->renderParagraph($block['content']), - 'header' => $this->renderHeader($block['content'], $block['level'] ?? 1), - 'code' => $this->renderCodeBlock($block['content'], $block['lang'] ?? null), - 'table' => $this->renderTable($block), - 'blockquote' => $this->renderBlockquote($block['content']), - 'horizontal_rule' => $this->renderHorizontalRule(), - default => '', - }; - } - protected function escapeText(string $text): string { $text = str_replace('&', '&', $text); diff --git a/src/Renderers/TelegramRenderer.php b/src/Renderers/TelegramRenderer.php index af3207b..6faac74 100644 --- a/src/Renderers/TelegramRenderer.php +++ b/src/Renderers/TelegramRenderer.php @@ -4,19 +4,6 @@ class TelegramRenderer extends AbstractRenderer { - protected function renderBlock(array $block): string - { - return match ($block['type']) { - 'paragraph' => $this->renderParagraph($block['content']), - 'header' => $this->renderHeader($block['content'], $block['level'] ?? 1), - 'code' => $this->renderCodeBlock($block['content'], $block['lang'] ?? null), - 'table' => $this->renderTable($block), - 'blockquote' => $this->renderBlockquote($block['content']), - 'horizontal_rule' => $this->renderHorizontalRule(), - default => '', - }; - } - protected function escapeText(string $text): string { return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); diff --git a/src/Renderers/WhatsAppRenderer.php b/src/Renderers/WhatsAppRenderer.php index e298ed5..2e235ad 100644 --- a/src/Renderers/WhatsAppRenderer.php +++ b/src/Renderers/WhatsAppRenderer.php @@ -4,19 +4,6 @@ class WhatsAppRenderer extends AbstractRenderer { - protected function renderBlock(array $block): string - { - return match ($block['type']) { - 'paragraph' => $this->renderParagraph($block['content']), - 'header' => $this->renderHeader($block['content'], $block['level'] ?? 1), - 'code' => $this->renderCodeBlock($block['content'], $block['lang'] ?? null), - 'table' => $this->renderTable($block), - 'blockquote' => $this->renderBlockquote($block['content']), - 'horizontal_rule' => $this->renderHorizontalRule(), - default => '', - }; - } - protected function renderHeader(string $content, int $level): string { return "*{$content}*"; From d59ad44ad654b80a637082cfabd3e6ca91a5596c Mon Sep 17 00:00:00 2001 From: AlexR1712 <8460736+AlexR1712@users.noreply.github.com> Date: Tue, 5 May 2026 22:51:01 +0000 Subject: [PATCH 8/8] Fix styling --- tests/Feature/MarkdownConverterTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Feature/MarkdownConverterTest.php b/tests/Feature/MarkdownConverterTest.php index f837eea..0dead00 100644 --- a/tests/Feature/MarkdownConverterTest.php +++ b/tests/Feature/MarkdownConverterTest.php @@ -3,6 +3,7 @@ use Blockshift\ChatMarkdown\MarkdownConverter; use Blockshift\ChatMarkdown\Renderers\TelegramRenderer; use Blockshift\ChatMarkdown\Renderers\WhatsAppRenderer; +use Blockshift\ChatMarkdown\Support\UnicodeStyler; it('can convert markdown to telegram', function () { $markdown = '**Hello** world'; @@ -192,7 +193,7 @@ it('processes inline formatting inside blockquotes for Instagram', function () { $result = MarkdownConverter::toInstagram('> Quoted **wow**'); - expect($result)->toContain(\Blockshift\ChatMarkdown\Support\UnicodeStyler::bold('wow')); + expect($result)->toContain(UnicodeStyler::bold('wow')); }); it('handles escaped characters', function () {