From afe8fc1e1e6a4149139a99b041b4ae9a1b93ddbb Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 11 May 2026 13:18:12 +0100 Subject: [PATCH] Render support ticket message markdown and ASCII tables as HTML Filament's TextEntry markdown rendering was collapsing single newlines, which mangled pasted artisan output (ASCII tables) into a single line on the admin support ticket view. Replace the bare `->markdown()` with a custom renderer that: - Detects consecutive `+`/`|` lines and converts them into real HTML tables (with a header when separator-delimited, otherwise tbody-only so key/value layouts stay aligned) - Enables soft-break -> `
` conversion so other line breaks survive - Applies inline styles (width: auto, padding, borders, zebra stripes, paragraph spacing) using semi-transparent neutrals so the result works in both light and dark Filament themes without depending on Tailwind Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Resources/SupportTicketResource.php | 127 +++++++++++++++++- tests/Feature/SupportTicketTest.php | 69 ++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/app/Filament/Resources/SupportTicketResource.php b/app/Filament/Resources/SupportTicketResource.php index 6c67f703..66aec521 100644 --- a/app/Filament/Resources/SupportTicketResource.php +++ b/app/Filament/Resources/SupportTicketResource.php @@ -13,6 +13,7 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Support\HtmlString; +use Illuminate\Support\Str; class SupportTicketResource extends Resource { @@ -77,7 +78,10 @@ public static function infolist(Schema $schema): Schema ->label('Subject'), Infolists\Components\TextEntry::make('message') ->label('Message') - ->markdown(), + ->formatStateUsing(fn (?string $state): ?HtmlString => $state === null + ? null + : new HtmlString(self::renderTicketMessage($state))) + ->html(), Infolists\Components\TextEntry::make('attachments') ->label('Attachments') ->formatStateUsing(function (SupportTicket $record): HtmlString { @@ -168,6 +172,127 @@ public static function getRelations(): array return []; } + public static function renderTicketMessage(string $message): string + { + $html = Str::markdown(self::convertAsciiTablesToHtml($message), [ + 'renderer' => [ + 'soft_break' => "
\n", + ], + ]); + + return str_replace('

', '

', $html); + } + + protected static function convertAsciiTablesToHtml(string $message): string + { + $lines = preg_split('/\R/', $message) ?: []; + $result = []; + $buffer = []; + + $flush = function () use (&$result, &$buffer): void { + if ($buffer === []) { + return; + } + + $rendered = self::renderAsciiTable($buffer); + + if ($rendered === null) { + foreach ($buffer as $bufferedLine) { + $result[] = $bufferedLine; + } + } else { + $result[] = ''; + $result[] = $rendered; + $result[] = ''; + } + + $buffer = []; + }; + + foreach ($lines as $line) { + if (preg_match('/^\s*[+|]/', $line)) { + $buffer[] = $line; + + continue; + } + + $flush(); + $result[] = $line; + } + + $flush(); + + return implode("\n", $result); + } + + protected static function renderAsciiTable(array $lines): ?string + { + $rows = []; + $separatorAfterRow = []; + + foreach ($lines as $line) { + $trimmed = ltrim($line); + + if (str_starts_with($trimmed, '+')) { + $separatorAfterRow[count($rows)] = true; + + continue; + } + + if (str_starts_with($trimmed, '|')) { + $rows[] = self::splitAsciiTableRow($trimmed); + } + } + + if ($rows === []) { + return null; + } + + $hasHeader = count($rows) > 1 && isset($separatorAfterRow[1]); + + $tableStyle = 'border-collapse: collapse; width: auto; margin: 0 0 1rem 0; border: 1px solid rgba(127, 127, 127, 0.25);'; + $cellStyle = 'padding: 0.25rem 0.75rem; border: 1px solid rgba(127, 127, 127, 0.2); text-align: left; vertical-align: top;'; + $headerCellStyle = $cellStyle.' font-weight: 600; background: rgba(127, 127, 127, 0.12);'; + $stripeStyle = 'background: rgba(127, 127, 127, 0.06);'; + + $html = ''; + + if ($hasHeader) { + $html .= ''; + foreach ($rows[0] as $cell) { + $html .= ''; + } + $html .= ''; + $bodyRows = array_slice($rows, 1); + } else { + $bodyRows = $rows; + } + + $html .= ''; + foreach ($bodyRows as $index => $row) { + $rowStyle = $index % 2 === 1 ? ' style="'.$stripeStyle.'"' : ''; + $html .= ''; + foreach ($row as $cell) { + $html .= ''; + } + $html .= ''; + } + $html .= '
'.e($cell).'
'.e($cell).'
'; + + return $html; + } + + /** + * @return list + */ + protected static function splitAsciiTableRow(string $line): array + { + $line = trim($line); + $line = trim($line, '|'); + + return array_map('trim', explode('|', $line)); + } + public static function getPages(): array { return [ diff --git a/tests/Feature/SupportTicketTest.php b/tests/Feature/SupportTicketTest.php index 0089418d..f1e37e4f 100644 --- a/tests/Feature/SupportTicketTest.php +++ b/tests/Feature/SupportTicketTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Filament\Resources\SupportTicketResource; use App\Filament\Resources\SupportTicketResource\Pages\ViewSupportTicket; use App\Filament\Resources\SupportTicketResource\Widgets\TicketRepliesWidget; use App\Livewire\Customer\Support\Create; @@ -1268,6 +1269,74 @@ public function admin_view_page_shows_name_and_email_when_user_has_name(): void ->assertSee($namedUser->email); } + #[Test] + public function admin_view_page_renders_message_markdown_and_ascii_tables_as_html_tables(): void + { + $admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + + $message = "**What I was trying to do:**\nDeploy quickly\n\n" + ."**Environment:**\n+--------------------+---------------+\n" + ."| Package Version | 3.3.3 |\n" + ."| PHP Version (Host) | 8.4.16 |\n" + .'+--------------------+---------------+'; + + $ticket = SupportTicket::factory()->create(['message' => $message]); + + $html = Livewire::actingAs($admin) + ->test(ViewSupportTicket::class, ['record' => $ticket->getRouteKey()]) + ->assertOk() + ->assertSeeHtml('What I was trying to do:') + ->html(); + + $this->assertMatchesRegularExpression('/]*>Package Version<\/td>/', $html); + $this->assertMatchesRegularExpression('/]*>3\.3\.3<\/td>/', $html); + } + + #[Test] + public function render_ticket_message_converts_ascii_table_without_header_to_html_table(): void + { + $message = "Intro line\n+---+---+\n| a | b |\n| c | d |\n+---+---+\nOutro line"; + + $html = SupportTicketResource::renderTicketMessage($message); + + $this->assertStringContainsString('Intro line', $html); + $this->assertStringNotContainsString('', $html); + $this->assertMatchesRegularExpression('/]*>a<\/td>]*>b<\/td><\/tr>/', $html); + $this->assertMatchesRegularExpression('/]*>c<\/td>]*>d<\/td><\/tr>/', $html); + $this->assertStringContainsString('Outro line', $html); + } + + #[Test] + public function render_ticket_message_treats_first_row_as_header_when_separated(): void + { + $message = "+----------+---------+\n| Package | Version |\n+----------+---------+\n| camera | 1.0.2 |\n+----------+---------+"; + + $html = SupportTicketResource::renderTicketMessage($message); + + $this->assertMatchesRegularExpression('/]*>Package<\/th>]*>Version<\/th><\/tr><\/thead>/', $html); + $this->assertMatchesRegularExpression('/]*>camera<\/td>]*>1\.0\.2<\/td><\/tr>/', $html); + } + + #[Test] + public function render_ticket_message_applies_paragraph_spacing(): void + { + $html = SupportTicketResource::renderTicketMessage("First paragraph\n\nSecond paragraph"); + + $this->assertStringContainsString('

First paragraph

', $html); + $this->assertStringContainsString('

Second paragraph

', $html); + } + + #[Test] + public function render_ticket_message_converts_single_newlines_to_line_breaks(): void + { + $message = "Line one\nLine two"; + + $html = SupportTicketResource::renderTicketMessage($message); + + $this->assertStringContainsString("Line one
\nLine two", $html); + } + #[Test] public function guests_cannot_access_ticket_index(): void {