From 6f32413c0a59019d894ac4ca3a75c6d28988ca6b Mon Sep 17 00:00:00 2001 From: Mujahid Abbas Date: Sun, 11 Jan 2026 18:41:26 +0500 Subject: [PATCH] feat: Add markdown preview with syntax highlighting - Add MarkdownService using league/commonmark with GFM support - Create markdown-editor Blade component with write/preview/split modes - Implement code block enhancer with highlight.js syntax highlighting - Add copy-to-clipboard functionality for code blocks - Create HasMarkdownPreview trait for Livewire components - Update PRD and Tech tabs to use new markdown-editor component - Style markdown preview with Tailwind typography plugin - Add comprehensive unit and feature tests --- .gitignore | 1 + app/Livewire/Concerns/HasMarkdownPreview.php | 32 +++ app/Livewire/Projects/Tabs/Prd.php | 3 + app/Livewire/Projects/Tabs/Tech.php | 3 + app/Providers/AppServiceProvider.php | 3 +- app/Services/MarkdownService.php | 37 +++ composer.json | 1 + composer.lock | 2 +- package-lock.json | 56 +++- package.json | 4 +- resources/css/app.css | 92 +++++++ resources/js/app.js | 45 ++++ resources/js/code-block-enhancer.js | 248 ++++++++++++++++++ .../components/markdown-editor.blade.php | 125 +++++++++ .../version-history-slide-over.blade.php | 12 +- .../livewire/projects/tabs/prd.blade.php | 8 +- .../livewire/projects/tabs/tech.blade.php | 8 +- tests/Feature/MarkdownPreviewTest.php | 175 ++++++++++++ tests/Unit/MarkdownServiceTest.php | 102 +++++++ 19 files changed, 944 insertions(+), 13 deletions(-) create mode 100644 app/Livewire/Concerns/HasMarkdownPreview.php create mode 100644 app/Services/MarkdownService.php create mode 100644 resources/js/code-block-enhancer.js create mode 100644 resources/views/components/markdown-editor.blade.php create mode 100644 tests/Feature/MarkdownPreviewTest.php create mode 100644 tests/Unit/MarkdownServiceTest.php diff --git a/.gitignore b/.gitignore index cd19e93..ad3060c 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ Thumbs.db /CLAUDE.md # AI tool configurations +/.ai /.cursor /.gemini /.junie diff --git a/app/Livewire/Concerns/HasMarkdownPreview.php b/app/Livewire/Concerns/HasMarkdownPreview.php new file mode 100644 index 0000000..59218cd --- /dev/null +++ b/app/Livewire/Concerns/HasMarkdownPreview.php @@ -0,0 +1,32 @@ +editorMode = $mode; + unset($this->previewHtml); + } + + #[Computed] + public function previewHtml(): string + { + return app(MarkdownService::class)->render($this->content); + } + + protected function clearPreviewCache(): void + { + unset($this->previewHtml); + } +} diff --git a/app/Livewire/Projects/Tabs/Prd.php b/app/Livewire/Projects/Tabs/Prd.php index 13fd16a..7be56a6 100644 --- a/app/Livewire/Projects/Tabs/Prd.php +++ b/app/Livewire/Projects/Tabs/Prd.php @@ -3,6 +3,7 @@ namespace App\Livewire\Projects\Tabs; use App\Enums\DocumentType; +use App\Livewire\Concerns\HasMarkdownPreview; use App\Livewire\Concerns\HasVersionHistory; use App\Models\Document; use App\Models\DocumentVersion; @@ -15,6 +16,7 @@ class Prd extends Component { use AuthorizesRequests; + use HasMarkdownPreview; use HasVersionHistory; public string $projectId; @@ -52,6 +54,7 @@ public function loadContent(): void public function updatedContent(): void { $this->isDirty = true; + $this->clearPreviewCache(); } public function save(): void diff --git a/app/Livewire/Projects/Tabs/Tech.php b/app/Livewire/Projects/Tabs/Tech.php index add3232..9fbf80f 100644 --- a/app/Livewire/Projects/Tabs/Tech.php +++ b/app/Livewire/Projects/Tabs/Tech.php @@ -5,6 +5,7 @@ use App\Actions\GenerateTasksFromTechSpec; use App\Enums\DocumentType; use App\Enums\PlanRunStepStatus; +use App\Livewire\Concerns\HasMarkdownPreview; use App\Livewire\Concerns\HasVersionHistory; use App\Models\Document; use App\Models\DocumentVersion; @@ -18,6 +19,7 @@ class Tech extends Component { use AuthorizesRequests; + use HasMarkdownPreview; use HasVersionHistory; public string $projectId; @@ -55,6 +57,7 @@ public function loadContent(): void public function updatedContent(): void { $this->isDirty = true; + $this->clearPreviewCache(); } public function save(): void diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a9ffe23..c56bc5b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ use App\Events\TasksChanged; use App\Listeners\QueueGitHubSync; +use App\Services\MarkdownService; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; @@ -16,7 +17,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(MarkdownService::class); } /** diff --git a/app/Services/MarkdownService.php b/app/Services/MarkdownService.php new file mode 100644 index 0000000..1122f7b --- /dev/null +++ b/app/Services/MarkdownService.php @@ -0,0 +1,37 @@ + 'strip', + 'allow_unsafe_links' => false, + ]); + + $environment->addExtension(new CommonMarkCoreExtension); + $environment->addExtension(new GithubFlavoredMarkdownExtension); + $environment->addExtension(new DisallowedRawHtmlExtension); + + $this->converter = new MarkdownConverter($environment); + } + + public function render(string $markdown): string + { + if (empty(trim($markdown))) { + return ''; + } + + return $this->converter->convert($markdown)->getContent(); + } +} diff --git a/composer.json b/composer.json index b0e22f4..cbc8a85 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "firebase/php-jwt": "^7.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", + "league/commonmark": "^2.8", "livewire/livewire": "^3.6.4", "livewire/volt": "^1.7.0", "prism-php/prism": "^0.99.7", diff --git a/composer.lock b/composer.lock index 4bb1c80..e6a7791 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ab53984dc2e43392e17f25cd02aca89e", + "content-hash": "9758506a65056052b0375998bd6d4035", "packages": [ { "name": "anourvalar/eloquent-serialize", diff --git a/package-lock.json b/package-lock.json index fb978bd..1ccb93a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,9 @@ "packages": { "": { "dependencies": { - "@tailwindcss/forms": "^0.5.11" + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.19", + "highlight.js": "^11.11.1" }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", @@ -1087,6 +1089,18 @@ "node": ">= 10" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@tailwindcss/vite": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", @@ -1373,6 +1387,18 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1753,6 +1779,15 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2181,6 +2216,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -2409,6 +2457,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", diff --git a/package.json b/package.json index 1dabf95..9648216 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "vite": "^7.0.7" }, "dependencies": { - "@tailwindcss/forms": "^0.5.11" + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.19", + "highlight.js": "^11.11.1" } } diff --git a/resources/css/app.css b/resources/css/app.css index 27895b1..3cdf5b8 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,6 +1,98 @@ @import 'tailwindcss'; @plugin '@tailwindcss/forms'; +@plugin '@tailwindcss/typography'; + +/* Highlight.js theme for syntax highlighting */ +@import 'highlight.js/styles/github-dark.css'; @source '../views/**/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; + +/* Markdown Preview Styling */ +.markdown-preview { + /* Inline code - pink on light gray */ + & :not(pre) > code { + @apply text-pink-600 bg-gray-100 px-1.5 py-0.5 rounded text-sm font-mono; + } + + /* Remove the backtick quotes from inline code */ + & :not(pre) > code::before, + & :not(pre) > code::after { + content: none; + } + + /* Code blocks container */ + & pre { + @apply bg-gray-900 rounded-lg overflow-x-auto relative; + } + + /* Code block content - let highlight.js handle colors */ + & pre code { + @apply bg-transparent p-4 text-sm leading-relaxed block; + } + + /* Code block header with language label and copy button */ + & .code-block-header { + @apply flex justify-between items-center px-4 py-2 + bg-gray-800 border-b border-gray-700 + text-xs text-gray-400 rounded-t-lg; + } + + & .code-language { + @apply font-medium tracking-wide; + } + + & .copy-btn { + @apply px-2.5 py-1 rounded text-gray-400 + hover:text-white hover:bg-gray-700 + transition-colors duration-150 cursor-pointer + border-0 bg-transparent; + } + + & .copy-btn.copied { + @apply text-green-400; + } + + /* When header exists, adjust code padding */ + & pre:has(.code-block-header) { + @apply rounded-t-none; + } + + & pre:has(.code-block-header) code { + @apply rounded-t-none; + } + + /* Tables - clean borders */ + & table { + @apply w-full border-collapse; + } + + & th { + @apply bg-gray-50 font-semibold text-left px-4 py-2 border border-gray-200; + } + + & td { + @apply px-4 py-2 border border-gray-200; + } + + /* Task lists */ + & input[type="checkbox"] { + @apply rounded border-gray-300 text-indigo-600 mr-2; + } + + /* Blockquotes */ + & blockquote { + @apply border-l-4 border-indigo-500 bg-indigo-50 pl-4 py-2 italic text-gray-700; + } + + /* Horizontal rules */ + & hr { + @apply border-gray-200 my-8; + } + + /* Images */ + & img { + @apply rounded-lg shadow-sm; + } +} diff --git a/resources/js/app.js b/resources/js/app.js index e59d6a0..151d024 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1 +1,46 @@ import './bootstrap'; + +// Highlight.js for syntax highlighting +import hljs from 'highlight.js/lib/core'; + +// Register common languages (keeps bundle size lean) +import javascript from 'highlight.js/lib/languages/javascript'; +import typescript from 'highlight.js/lib/languages/typescript'; +import php from 'highlight.js/lib/languages/php'; +import python from 'highlight.js/lib/languages/python'; +import sql from 'highlight.js/lib/languages/sql'; +import bash from 'highlight.js/lib/languages/bash'; +import json from 'highlight.js/lib/languages/json'; +import xml from 'highlight.js/lib/languages/xml'; +import css from 'highlight.js/lib/languages/css'; +import plaintext from 'highlight.js/lib/languages/plaintext'; +import yaml from 'highlight.js/lib/languages/yaml'; +import markdown from 'highlight.js/lib/languages/markdown'; + +hljs.registerLanguage('javascript', javascript); +hljs.registerLanguage('js', javascript); +hljs.registerLanguage('typescript', typescript); +hljs.registerLanguage('ts', typescript); +hljs.registerLanguage('php', php); +hljs.registerLanguage('python', python); +hljs.registerLanguage('py', python); +hljs.registerLanguage('sql', sql); +hljs.registerLanguage('bash', bash); +hljs.registerLanguage('shell', bash); +hljs.registerLanguage('sh', bash); +hljs.registerLanguage('json', json); +hljs.registerLanguage('xml', xml); +hljs.registerLanguage('html', xml); +hljs.registerLanguage('css', css); +hljs.registerLanguage('plaintext', plaintext); +hljs.registerLanguage('text', plaintext); +hljs.registerLanguage('yaml', yaml); +hljs.registerLanguage('yml', yaml); +hljs.registerLanguage('markdown', markdown); +hljs.registerLanguage('md', markdown); + +// Expose for Alpine.js components +window.hljs = hljs; + +// Import Alpine components +import './code-block-enhancer'; diff --git a/resources/js/code-block-enhancer.js b/resources/js/code-block-enhancer.js new file mode 100644 index 0000000..088e781 --- /dev/null +++ b/resources/js/code-block-enhancer.js @@ -0,0 +1,248 @@ +/** + * Alpine.js component for enhancing code blocks in markdown preview. + * Adds syntax highlighting, language labels, and copy buttons. + */ +export default function codeBlockEnhancer() { + return { + observer: null, + + init() { + // Run enhancement after initial render + this.$nextTick(() => this.enhance()); + + // Use MutationObserver to re-enhance when DOM changes + // This catches Livewire morphing, content updates, etc. + const self = this; + this.observer = new MutationObserver((mutations) => { + // Check if code blocks were added or if content changed + const needsEnhancement = mutations.some(mutation => { + // Check for added nodes containing code blocks + if (mutation.addedNodes.length > 0) { + return Array.from(mutation.addedNodes).some(node => + node.nodeType === 1 && ( + node.matches?.('pre') || + node.querySelector?.('pre code') + ) + ); + } + // Check if mutation target is a pre element + if (mutation.target.matches?.('pre')) { + return true; + } + return false; + }); + + if (needsEnhancement) { + self.$nextTick(() => self.enhance()); + } + }); + + this.observer.observe(this.$el, { + childList: true, + subtree: true + }); + }, + + destroy() { + if (this.observer) { + this.observer.disconnect(); + } + }, + + enhance() { + if (!window.hljs) return; + + const codeBlocks = this.$el.querySelectorAll('pre code'); + + codeBlocks.forEach(codeElement => { + const pre = codeElement.parentElement; + if (!pre || pre.tagName !== 'PRE') return; + + // Check if header already exists (avoid duplicates) + const existingHeader = pre.querySelector('.code-block-header'); + if (existingHeader) return; + + // Detect language from class + const language = this.detectLanguage(codeElement); + + // Apply syntax highlighting (only if not already highlighted) + if (!codeElement.classList.contains('hljs') && language !== 'plaintext') { + try { + window.hljs.highlightElement(codeElement); + } catch (e) { + // Fallback silently if highlighting fails + } + } + + // Add header with language label and copy button + this.addCodeBlockHeader(pre, codeElement, language); + }); + }, + + detectLanguage(codeElement) { + const classes = codeElement.className.split(' '); + const langClass = classes.find(c => c.startsWith('language-')); + + if (langClass) { + return langClass.replace('language-', ''); + } + + // Check if it looks like ASCII art (no highlighting needed) + const content = codeElement.textContent || ''; + if (this.looksLikeAsciiArt(content)) { + return 'plaintext'; + } + + return 'plaintext'; + }, + + looksLikeAsciiArt(content) { + // ASCII art typically has lots of box-drawing characters or pipes/dashes + const boxChars = /[┌┐└┘├┤┬┴┼│─|+\-=]/g; + const matches = content.match(boxChars) || []; + const ratio = matches.length / content.length; + + // If more than 5% of content is box-drawing chars, treat as ASCII art + return ratio > 0.05; + }, + + addCodeBlockHeader(pre, codeElement, language) { + // Make pre relative for positioning + pre.style.position = 'relative'; + + // Create header container + const header = document.createElement('div'); + header.className = 'code-block-header'; + + // Language label + const langLabel = document.createElement('span'); + langLabel.className = 'code-language'; + langLabel.textContent = this.formatLanguageName(language); + + // Copy button + const copyBtn = document.createElement('button'); + copyBtn.className = 'copy-btn'; + copyBtn.type = 'button'; + copyBtn.textContent = 'Copy'; + + // Store code content for copying (get original text before highlighting) + const codeContent = codeElement.textContent; + + copyBtn.addEventListener('click', async () => { + try { + // Try modern clipboard API first (requires HTTPS) + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(codeContent); + } else { + // Fallback for non-secure contexts (HTTP) + const textArea = document.createElement('textarea'); + textArea.value = codeContent; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + textArea.style.top = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + } + + copyBtn.textContent = 'Copied!'; + copyBtn.classList.add('copied'); + + setTimeout(() => { + copyBtn.textContent = 'Copy'; + copyBtn.classList.remove('copied'); + }, 2000); + } catch (err) { + copyBtn.textContent = 'Failed'; + setTimeout(() => { + copyBtn.textContent = 'Copy'; + }, 2000); + } + }); + + // Assemble header + header.appendChild(langLabel); + header.appendChild(copyBtn); + + // Insert header before code + pre.insertBefore(header, pre.firstChild); + }, + + formatLanguageName(language) { + const nameMap = { + 'js': 'JavaScript', + 'javascript': 'JavaScript', + 'ts': 'TypeScript', + 'typescript': 'TypeScript', + 'php': 'PHP', + 'py': 'Python', + 'python': 'Python', + 'sql': 'SQL', + 'bash': 'Bash', + 'shell': 'Shell', + 'sh': 'Shell', + 'json': 'JSON', + 'xml': 'XML', + 'html': 'HTML', + 'css': 'CSS', + 'yaml': 'YAML', + 'yml': 'YAML', + 'md': 'Markdown', + 'markdown': 'Markdown', + 'plaintext': 'Plain Text', + 'text': 'Plain Text' + }; + + return nameMap[language] || language.toUpperCase(); + } + }; +} + +// Register as Alpine component +document.addEventListener('alpine:init', () => { + Alpine.data('codeBlockEnhancer', codeBlockEnhancer); +}); + +// Re-enhance after Livewire fully initializes (handles initial hydration) +document.addEventListener('livewire:init', () => { + // Small delay to ensure DOM is fully ready after hydration + setTimeout(() => { + if (window.hljs) { + document.querySelectorAll('.markdown-preview pre code').forEach(codeElement => { + const pre = codeElement.parentElement; + if (pre && !pre.querySelector('.code-block-header')) { + // Trigger re-initialization of Alpine components + const alpineEl = pre.closest('[x-data]'); + if (alpineEl && alpineEl.__x) { + alpineEl.__x.$data.enhance?.(); + } + } + }); + } + }, 50); +}); + +// Also run enhancement globally after Livewire page navigation +// This catches cases where the Alpine component might be destroyed/recreated +document.addEventListener('livewire:navigated', () => { + setTimeout(() => { + // Directly enhance any unprocessed code blocks + if (window.hljs) { + document.querySelectorAll('.markdown-preview pre code').forEach(codeElement => { + const pre = codeElement.parentElement; + if (pre && !pre.querySelector('.code-block-header')) { + // Apply highlighting if not already done + if (!codeElement.classList.contains('hljs')) { + try { + window.hljs.highlightElement(codeElement); + } catch (e) { + // Silently fail + } + } + } + }); + } + }, 100); +}); diff --git a/resources/views/components/markdown-editor.blade.php b/resources/views/components/markdown-editor.blade.php new file mode 100644 index 0000000..37d6d69 --- /dev/null +++ b/resources/views/components/markdown-editor.blade.php @@ -0,0 +1,125 @@ +@props([ + 'content' => '', + 'placeholder' => '# Document\n\n## Overview\nStart writing...', + 'editorMode' => 'write', +]) + +
+ {{-- Mode Toggle Toolbar --}} +
+ + + +
+ + {{-- Editor Content --}} +
+ {{-- Write Panel --}} +
+ +
+ + {{-- Preview Panel --}} +
+ @if(empty(trim($content))) +
+
+ + + +

Nothing to preview

+

Start writing to see the preview

+
+
+ @else +
+ {!! $this->previewHtml !!} +
+ @endif +
+
+
diff --git a/resources/views/components/version-history-slide-over.blade.php b/resources/views/components/version-history-slide-over.blade.php index 3577e71..14208c2 100644 --- a/resources/views/components/version-history-slide-over.blade.php +++ b/resources/views/components/version-history-slide-over.blade.php @@ -5,6 +5,8 @@ 'currentVersionId' => null, ]) +@inject('markdown', 'App\Services\MarkdownService') +
-
{{ $selectedVersion->content_md }}
+
+ {!! $markdown->render($selectedVersion->content_md) !!} +
@else {{-- Empty state --}} diff --git a/resources/views/livewire/projects/tabs/prd.blade.php b/resources/views/livewire/projects/tabs/prd.blade.php index 66e1c7c..4d16ecd 100644 --- a/resources/views/livewire/projects/tabs/prd.blade.php +++ b/resources/views/livewire/projects/tabs/prd.blade.php @@ -51,11 +51,11 @@ class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transi @else
- + />
@endif diff --git a/resources/views/livewire/projects/tabs/tech.blade.php b/resources/views/livewire/projects/tabs/tech.blade.php index 9170505..7277bfe 100644 --- a/resources/views/livewire/projects/tabs/tech.blade.php +++ b/resources/views/livewire/projects/tabs/tech.blade.php @@ -84,11 +84,11 @@ class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transi @else
- + />
@endif diff --git a/tests/Feature/MarkdownPreviewTest.php b/tests/Feature/MarkdownPreviewTest.php new file mode 100644 index 0000000..bb3f63b --- /dev/null +++ b/tests/Feature/MarkdownPreviewTest.php @@ -0,0 +1,175 @@ +user = User::factory()->create(); + $this->project = Project::factory()->for($this->user)->create(); +}); + +describe('PRD Markdown Preview', function () { + beforeEach(function () { + $this->document = Document::factory() + ->prd() + ->for($this->project) + ->create(); + + $this->version = DocumentVersion::factory() + ->for($this->document, 'document') + ->withContent('# Test PRD\n\nThis is a **bold** test.') + ->create(['created_by' => $this->user->id]); + + $this->document->update(['current_version_id' => $this->version->id]); + }); + + it('initializes with write mode by default', function () { + actingAs($this->user); + + $component = Livewire\Livewire::test(\App\Livewire\Projects\Tabs\Prd::class, [ + 'projectId' => $this->project->id, + ]); + + $component->assertSet('editorMode', 'write'); + }); + + it('can switch to preview mode', function () { + actingAs($this->user); + + $component = Livewire\Livewire::test(\App\Livewire\Projects\Tabs\Prd::class, [ + 'projectId' => $this->project->id, + ]); + + $component + ->call('setEditorMode', 'preview') + ->assertSet('editorMode', 'preview'); + }); + + it('can switch to split mode', function () { + actingAs($this->user); + + $component = Livewire\Livewire::test(\App\Livewire\Projects\Tabs\Prd::class, [ + 'projectId' => $this->project->id, + ]); + + $component + ->call('setEditorMode', 'split') + ->assertSet('editorMode', 'split'); + }); + + it('rejects invalid modes', function () { + actingAs($this->user); + + $component = Livewire\Livewire::test(\App\Livewire\Projects\Tabs\Prd::class, [ + 'projectId' => $this->project->id, + ]); + + $component + ->call('setEditorMode', 'invalid') + ->assertSet('editorMode', 'write'); + }); + + it('computes preview HTML from content', function () { + actingAs($this->user); + + $component = Livewire\Livewire::test(\App\Livewire\Projects\Tabs\Prd::class, [ + 'projectId' => $this->project->id, + ]); + + $component->set('content', '# Hello World'); + + $instance = $component->instance(); + $previewHtml = $instance->previewHtml; + + expect($previewHtml)->toContain('

Hello World

'); + }); + + it('clears preview cache when content changes', function () { + actingAs($this->user); + + $component = Livewire\Livewire::test(\App\Livewire\Projects\Tabs\Prd::class, [ + 'projectId' => $this->project->id, + ]); + + $component->set('content', '# First'); + $instance = $component->instance(); + $firstHtml = $instance->previewHtml; + expect($firstHtml)->toContain('

First

'); + + $component->set('content', '# Second'); + $instance = $component->instance(); + $secondHtml = $instance->previewHtml; + expect($secondHtml)->toContain('

Second

'); + }); + + it('returns empty string for empty content', function () { + actingAs($this->user); + + $component = Livewire\Livewire::test(\App\Livewire\Projects\Tabs\Prd::class, [ + 'projectId' => $this->project->id, + ]); + + $component->set('content', ''); + + $instance = $component->instance(); + expect($instance->previewHtml)->toBe(''); + }); +}); + +describe('Tech Spec Markdown Preview', function () { + beforeEach(function () { + $this->document = Document::factory() + ->tech() + ->for($this->project) + ->create(); + + $this->version = DocumentVersion::factory() + ->for($this->document, 'document') + ->withContent('# Tech Spec\n\n## Architecture') + ->create(['created_by' => $this->user->id]); + + $this->document->update(['current_version_id' => $this->version->id]); + }); + + it('has the same markdown preview functionality as PRD', function () { + actingAs($this->user); + + $component = Livewire\Livewire::test(\App\Livewire\Projects\Tabs\Tech::class, [ + 'projectId' => $this->project->id, + ]); + + $component + ->assertSet('editorMode', 'write') + ->call('setEditorMode', 'preview') + ->assertSet('editorMode', 'preview') + ->call('setEditorMode', 'split') + ->assertSet('editorMode', 'split'); + }); + + it('computes preview HTML correctly', function () { + actingAs($this->user); + + $component = Livewire\Livewire::test(\App\Livewire\Projects\Tabs\Tech::class, [ + 'projectId' => $this->project->id, + ]); + + $markdown = <<<'MD' +## Architecture + +- API Gateway +- Database +MD; + + $component->set('content', $markdown); + + $instance = $component->instance(); + $previewHtml = $instance->previewHtml; + + expect($previewHtml)->toContain('

Architecture

'); + expect($previewHtml)->toContain('
  • API Gateway
  • '); + }); +}); diff --git a/tests/Unit/MarkdownServiceTest.php b/tests/Unit/MarkdownServiceTest.php new file mode 100644 index 0000000..3e3659e --- /dev/null +++ b/tests/Unit/MarkdownServiceTest.php @@ -0,0 +1,102 @@ +service = app(MarkdownService::class); +}); + +describe('MarkdownService', function () { + it('renders basic markdown to HTML', function () { + $markdown = '# Hello World'; + $html = $this->service->render($markdown); + + expect($html)->toContain('

    Hello World

    '); + }); + + it('renders GFM tables', function () { + $markdown = <<<'MD' +| Feature | Status | +|---------|--------| +| Tables | Yes | +MD; + + $html = $this->service->render($markdown); + + expect($html)->toContain(''); + expect($html)->toContain(''); + expect($html)->toContain(''); + }); + + it('renders GFM task lists', function () { + $markdown = <<<'MD' +- [x] Completed task +- [ ] Pending task +MD; + + $html = $this->service->render($markdown); + + expect($html)->toContain('type="checkbox"'); + expect($html)->toContain('checked'); + }); + + it('renders code blocks', function () { + $markdown = <<<'MD' +```php +echo "Hello"; +``` +MD; + + $html = $this->service->render($markdown); + + expect($html)->toContain('toContain('echo'); + }); + + it('renders inline code', function () { + $markdown = 'Use `composer install` to install.'; + $html = $this->service->render($markdown); + + expect($html)->toContain('composer install'); + }); + + it('renders links', function () { + $markdown = '[Laravel](https://laravel.com)'; + $html = $this->service->render($markdown); + + expect($html)->toContain('Laravel'); + }); + + it('returns empty string for empty input', function () { + expect($this->service->render(''))->toBe(''); + expect($this->service->render(' '))->toBe(''); + }); + + it('strips raw HTML for security', function () { + $markdown = ''; + $html = $this->service->render($markdown); + + expect($html)->not->toContain('
    FeatureTables