From 04abf231bd74cbf774873e731cc554e206a9d820 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Wed, 8 Apr 2026 17:34:55 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20rich=20Markdown=20normalizati?= =?UTF-8?q?on=20and=20paste=20handling.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ๐Ÿ›  Introduce `RichTextMarkdownNormalizer` to convert Markdown and mixed input to cleaner HTML. - ๐Ÿ—‚ Include a new Blade partial to enable Markdown-on-paste behavior in rich-text editors. - ๐Ÿ“‹ Enhance `create` and `edit` forms to normalize descriptions and support Markdown conversion. - ๐Ÿงช Add test coverage for Markdown normalization scenarios. - ๐Ÿ›  Add CLI command to normalize project proposal descriptions in bulk. - ๐Ÿ”ง Update `vite.config.js` for improved development setup (e.g., ignored paths). --- .../NormalizeProjectProposalDescriptions.php | 101 ++++++++ app/Support/RichTextMarkdownNormalizer.php | 238 ++++++++++++++++++ .../project-support/form/create.blade.php | 10 +- .../project-support/form/edit.blade.php | 10 +- .../project-support/show.blade.php | 6 +- .../markdown-paste-listener.blade.php | 97 +++++++ .../RichTextMarkdownNormalizerTest.php | 111 ++++++++ vite.config.js | 14 +- 8 files changed, 579 insertions(+), 8 deletions(-) create mode 100644 app/Console/Commands/NormalizeProjectProposalDescriptions.php create mode 100644 app/Support/RichTextMarkdownNormalizer.php create mode 100644 resources/views/partials/markdown-paste-listener.blade.php create mode 100644 tests/Feature/RichTextMarkdownNormalizerTest.php diff --git a/app/Console/Commands/NormalizeProjectProposalDescriptions.php b/app/Console/Commands/NormalizeProjectProposalDescriptions.php new file mode 100644 index 0000000..ab5f98c --- /dev/null +++ b/app/Console/Commands/NormalizeProjectProposalDescriptions.php @@ -0,0 +1,101 @@ +option('dry-run'); + $showDiff = (bool) $this->option('show-diff'); + $ids = array_filter((array) $this->option('id')); + + $normalizer = new RichTextMarkdownNormalizer; + + $query = ProjectProposal::query()->orderBy('id'); + + if ($ids !== []) { + $query->whereIn('id', $ids); + } + + $total = (clone $query)->count(); + + if ($total === 0) { + $this->warn('No project proposals to process.'); + + return self::SUCCESS; + } + + $this->info(sprintf( + '%s %d project proposal description(s)%s.', + $dryRun ? 'Analyzing' : 'Normalizing', + $total, + $dryRun ? ' (dry-run)' : '', + )); + + $changed = 0; + $unchanged = 0; + $failed = 0; + + $query->lazy()->each(function (ProjectProposal $proposal) use ($normalizer, $dryRun, $showDiff, &$changed, &$unchanged, &$failed): void { + $original = (string) ($proposal->description ?? ''); + + try { + $normalized = (string) ($normalizer->normalize($original) ?? ''); + } catch (\Throwable $exception) { + $failed++; + $this->error(sprintf('#%d %s โ€” normalization failed: %s', $proposal->id, $proposal->name, $exception->getMessage())); + + return; + } + + if ($original === $normalized) { + $unchanged++; + + return; + } + + $changed++; + $this->line(sprintf('~ #%d %s', $proposal->id, $proposal->name)); + + if ($showDiff) { + $this->line(' - '.$this->preview($original).''); + $this->line(' + '.$this->preview($normalized).''); + } + + if (! $dryRun) { + $proposal->description = $normalized; + $proposal->saveQuietly(); + } + }); + + $this->newLine(); + $this->info(sprintf( + 'Done. Changed: %d, unchanged: %d, failed: %d%s', + $changed, + $unchanged, + $failed, + $dryRun ? ' (no writes performed)' : '', + )); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + private function preview(string $value): string + { + $collapsed = preg_replace('/\s+/', ' ', trim($value)) ?? ''; + + return mb_strimwidth($collapsed, 0, 140, 'โ€ฆ'); + } +} diff --git a/app/Support/RichTextMarkdownNormalizer.php b/app/Support/RichTextMarkdownNormalizer.php new file mode 100644 index 0000000..2a784d8 --- /dev/null +++ b/app/Support/RichTextMarkdownNormalizer.php @@ -0,0 +1,238 @@ + tags line by line, so + * pasted Markdown like `# Heading` ends up as `

# Heading

` and never + * gets rendered as a real heading. This normalizer detects that situation + * and runs the extracted text through a Markdown renderer. + * + * If the HTML already contains structural elements produced by the editor + * toolbar (headings, lists, blockquotes, code blocks), the content is left + * untouched so real rich-text edits are preserved. + */ +class RichTextMarkdownNormalizer +{ + /** + * @var array HTML tags that indicate the user already + * used the editor toolbar for structure. + */ + private const STRUCTURAL_TAGS = [ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'blockquote', 'pre', 'table', + ]; + + /** + * Regex fragments that indicate plain-text Markdown syntax. + * + * @var array + */ + private const MARKDOWN_LINE_PATTERNS = [ + '/^\s{0,3}#{1,6}\s+\S/m', // ATX headings + '/^\s{0,3}[-*+]\s+\S/m', // bullet list + '/^\s{0,3}\d{1,9}[.)]\s+\S/m', // ordered list + '/^\s{0,3}>\s?/m', // blockquote + '/^\s{0,3}```/m', // fenced code block + ]; + + /** + * Render a raw Markdown string directly to HTML using the same + * configuration as normalize(). + */ + public function toHtml(string $markdown): string + { + return trim($this->renderer()->toHtml($markdown)); + } + + public function normalize(?string $html): ?string + { + if ($html === null || trim($html) === '') { + return $html; + } + + // Pure plain text (no HTML tags at all): render directly as Markdown + // so newline-separated paragraphs, headings, links, etc. become HTML. + if (! $this->containsHtmlTags($html)) { + $rendered = trim($this->renderer()->toHtml($html)); + + return $rendered === '' ? $html : $rendered; + } + + // Already structured via the editor toolbar: leave untouched. + if ($this->containsStructuralHtml($html)) { + return $html; + } + + // Paragraph-only HTML: if the inner text looks like Markdown + // (pasted plain text wrapped in

by Tiptap), extract and render. + $plainText = $this->extractPlainTextPreservingLineBreaks($html); + + if (! $this->looksLikeMarkdown($plainText)) { + return $html; + } + + $rendered = trim($this->renderer()->toHtml($plainText)); + + return $rendered === '' ? $html : $rendered; + } + + private function containsHtmlTags(string $input): bool + { + return preg_match('/<[a-z!\/][^>]*>/i', $input) === 1; + } + + private function renderer(): MarkdownRenderer + { + $config = config('markdown'); + + return new MarkdownRenderer( + commonmarkOptions: $config['commonmark_options'] ?? [], + highlightCode: $config['code_highlighting']['enabled'] ?? false, + highlightTheme: $config['code_highlighting']['theme'] ?? 'github-light', + cacheStoreName: $config['cache_store'] ?? null, + renderAnchors: $config['add_anchors_to_headings'] ?? false, + renderAnchorsAsLinks: $config['render_anchors_as_links'] ?? false, + extensions: $config['extensions'] ?? [], + blockRenderers: $config['block_renderers'] ?? [], + inlineRenderers: $config['inline_renderers'] ?? [], + inlineParsers: $config['inline_parsers'] ?? [], + cacheDuration: $config['cache_duration'] ?? null, + ); + } + + private function containsStructuralHtml(string $html): bool + { + foreach (self::STRUCTURAL_TAGS as $tag) { + if (stripos($html, '<'.$tag) !== false) { + return true; + } + } + + return false; + } + + private function looksLikeMarkdown(string $text): bool + { + foreach (self::MARKDOWN_LINE_PATTERNS as $pattern) { + if (preg_match($pattern, $text) === 1) { + return true; + } + } + + return false; + } + + private function extractPlainTextPreservingLineBreaks(string $html): string + { + $dom = new DOMDocument; + $previous = libxml_use_internal_errors(true); + $dom->loadHTML('

'.$html.'
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + $root = $dom->getElementsByTagName('div')->item(0); + + if ($root === null) { + return html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5); + } + + $blocks = []; + + foreach ($root->childNodes as $child) { + if ($child->nodeType === XML_ELEMENT_NODE && strtolower($child->nodeName) === 'p') { + $blocks[] = $this->nodeToMarkdown($child); + } else { + $blocks[] = trim($this->nodeToMarkdown($child)); + } + } + + return trim(implode("\n\n", array_filter($blocks, static fn ($line) => $line !== ''))); + } + + /** + * Walk a DOM node and produce a Markdown-equivalent string for its + * contents, preserving inline formatting (strong, em, code, links, + * images) and converting
to newlines. + */ + private function nodeToMarkdown(\DOMNode $node): string + { + $buffer = ''; + + foreach ($node->childNodes as $child) { + if ($child->nodeType === XML_TEXT_NODE) { + $buffer .= $child->textContent ?? ''; + + continue; + } + + if ($child->nodeType !== XML_ELEMENT_NODE) { + continue; + } + + /** @var \DOMElement $child */ + $tag = strtolower($child->nodeName); + + switch ($tag) { + case 'br': + $buffer .= "\n"; + break; + + case 'strong': + case 'b': + $inner = $this->nodeToMarkdown($child); + $buffer .= $inner === '' ? '' : '**'.$inner.'**'; + break; + + case 'em': + case 'i': + $inner = $this->nodeToMarkdown($child); + $buffer .= $inner === '' ? '' : '*'.$inner.'*'; + break; + + case 's': + case 'del': + case 'strike': + $inner = $this->nodeToMarkdown($child); + $buffer .= $inner === '' ? '' : '~~'.$inner.'~~'; + break; + + case 'code': + $buffer .= '`'.($child->textContent ?? '').'`'; + break; + + case 'a': + $text = $this->nodeToMarkdown($child); + $href = $child->getAttribute('href'); + if ($href === '') { + $buffer .= $text; + } elseif (trim($text) === '' || $text === $href) { + $buffer .= $href; + } else { + $buffer .= '['.$text.']('.$href.')'; + } + break; + + case 'img': + $src = $child->getAttribute('src'); + $alt = $child->getAttribute('alt'); + if ($src !== '') { + $buffer .= '!['.$alt.']('.$src.')'; + } + break; + + default: + $buffer .= $this->nodeToMarkdown($child); + break; + } + } + + return $buffer; + } +} diff --git a/resources/views/livewire/association/project-support/form/create.blade.php b/resources/views/livewire/association/project-support/form/create.blade.php index 06bdbcc..7fa59ea 100644 --- a/resources/views/livewire/association/project-support/form/create.blade.php +++ b/resources/views/livewire/association/project-support/form/create.blade.php @@ -2,6 +2,7 @@ use App\Models\ProjectProposal; use App\Support\NostrAuth; +use App\Support\RichTextMarkdownNormalizer; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; @@ -58,6 +59,11 @@ class extends Component } } + public function convertMarkdownToHtml(string $markdown): string + { + return (new RichTextMarkdownNormalizer)->toHtml($markdown); + } + public function save(): void { Gate::forUser(NostrAuth::user())->authorize('create', ProjectProposal::class); @@ -82,7 +88,7 @@ class extends Component $projectProposal = new ProjectProposal; $projectProposal->name = $this->form['name']; - $projectProposal->description = $this->form['description']; + $projectProposal->description = (new RichTextMarkdownNormalizer)->normalize($this->form['description']); $projectProposal->support_in_sats = (int) $this->form['support_in_sats']; $projectProposal->website = $this->form['website']; $projectProposal->accepted = $this->isAdmin ? $this->form['accepted'] : false; @@ -196,6 +202,8 @@ class extends Component + + @include('partials.markdown-paste-listener') @else
diff --git a/resources/views/livewire/association/project-support/form/edit.blade.php b/resources/views/livewire/association/project-support/form/edit.blade.php index d8af6fc..d6279ea 100644 --- a/resources/views/livewire/association/project-support/form/edit.blade.php +++ b/resources/views/livewire/association/project-support/form/edit.blade.php @@ -2,6 +2,7 @@ use App\Models\ProjectProposal; use App\Support\NostrAuth; +use App\Support\RichTextMarkdownNormalizer; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; @@ -77,6 +78,11 @@ class extends Component } } + public function convertMarkdownToHtml(string $markdown): string + { + return (new RichTextMarkdownNormalizer)->toHtml($markdown); + } + public function update(): void { Gate::forUser(NostrAuth::user())->authorize('update', $this->project); @@ -104,7 +110,7 @@ class extends Component $this->project->update([ 'name' => $this->form['name'], - 'description' => $this->form['description'], + 'description' => (new RichTextMarkdownNormalizer)->normalize($this->form['description']), 'support_in_sats' => (int) $this->form['support_in_sats'], 'website' => $this->form['website'], ]); @@ -247,6 +253,8 @@ class extends Component
+ + @include('partials.markdown-paste-listener') @else
diff --git a/resources/views/livewire/association/project-support/show.blade.php b/resources/views/livewire/association/project-support/show.blade.php index 2cda8cd..2183d92 100644 --- a/resources/views/livewire/association/project-support/show.blade.php +++ b/resources/views/livewire/association/project-support/show.blade.php @@ -137,10 +137,8 @@ new class extends Component {

{{ $projectProposal->name }}

-
- - {!! $projectProposal->description !!} - +
+ {!! $projectProposal->description !!}
diff --git a/resources/views/partials/markdown-paste-listener.blade.php b/resources/views/partials/markdown-paste-listener.blade.php new file mode 100644 index 0000000..604c20b --- /dev/null +++ b/resources/views/partials/markdown-paste-listener.blade.php @@ -0,0 +1,97 @@ +@script + +@endscript diff --git a/tests/Feature/RichTextMarkdownNormalizerTest.php b/tests/Feature/RichTextMarkdownNormalizerTest.php new file mode 100644 index 0000000..6a5c9de --- /dev/null +++ b/tests/Feature/RichTextMarkdownNormalizerTest.php @@ -0,0 +1,111 @@ +normalizer = new RichTextMarkdownNormalizer; +}); + +it('returns null and empty values untouched', function () { + expect($this->normalizer->normalize(null))->toBeNull(); + expect($this->normalizer->normalize(''))->toBe(''); + expect($this->normalizer->normalize(' '))->toBe(' '); +}); + +it('converts heading markdown wrapped in paragraph tags', function () { + $html = '

# EINUNDZWANZIG STANDUP

## Wer ich bin

Regular text.

'; + + $result = $this->normalizer->normalize($html); + + expect($result)->toContain('toContain('EINUNDZWANZIG STANDUP'); + expect($result)->toContain('toContain('Wer ich bin'); + expect($result)->toContain('Regular text.'); +}); + +it('converts bullet list markdown wrapped in paragraph tags', function () { + $html = '

- first item

- second item

- third item

'; + + $result = $this->normalizer->normalize($html); + + expect($result)->toContain('
    '); + expect($result)->toContain('first item'); + expect($result)->toContain('second item'); + expect($result)->toContain('third item'); + expect(substr_count($result, '
  • '))->toBe(3); +}); + +it('leaves structural html untouched when headings already exist', function () { + $html = '

    Real heading

    # not a heading

    '; + + expect($this->normalizer->normalize($html))->toBe($html); +}); + +it('leaves structural html untouched when list tags already exist', function () { + $html = '
    • existing

    - not a list

    '; + + expect($this->normalizer->normalize($html))->toBe($html); +}); + +it('leaves plain paragraph html untouched when it is not markdown', function () { + $html = '

    Just some normal text without any markdown syntax.

    '; + + expect($this->normalizer->normalize($html))->toBe($html); +}); + +it('renders pure plain text with paragraph breaks as html paragraphs', function () { + $text = "First paragraph with some text.\n\nSecond paragraph follows."; + + $result = $this->normalizer->normalize($text); + + expect($result)->toContain('

    First paragraph with some text.

    '); + expect($result)->toContain('

    Second paragraph follows.

    '); +}); + +it('renders plain text markdown (headings, lists, images) as html', function () { + $text = "## Heading Two\n\nSome intro line.\n\n- first\n- second\n\n![alt](https://example.com/img.png)"; + + $result = $this->normalizer->normalize($text); + + expect($result)->toContain('toContain('Heading Two'); + expect($result)->toContain('
      '); + expect($result)->toContain('
    • first
    • '); + expect($result)->toContain('toContain('https://example.com/img.png'); +}); + +it('is idempotent when re-run on already-rendered output', function () { + $text = "## Heading\n\nBody text."; + + $first = $this->normalizer->normalize($text); + $second = $this->normalizer->normalize($first); + + expect($second)->toBe($first); +}); + +it('preserves inline bold, code and links when converting pasted markdown', function () { + $html = '

      Antragsteller: DrShift โ€” user@example.com

      ' + .'

      Website

      ' + .'

      # Heading

      '; + + $result = $this->normalizer->normalize($html); + + expect($result)->toContain('toContain('Heading'); + expect($result)->toContain('Antragsteller:'); + expect($result)->toContain('user@example.com'); + expect($result)->toContain('Website'); +}); + +it('preserves images embedded via img tags', function () { + $html = '

      # Heading

      caption

      '; + + $result = $this->normalizer->normalize($html); + + expect($result)->toContain('toContain('toContain('src="https://example.com/i.png"'); + expect($result)->toContain('alt="caption"'); +}); diff --git a/vite.config.js b/vite.config.js index 12d48de..54b24ed 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,16 +1,26 @@ import {defineConfig} from 'vite'; +import tailwindcss from '@tailwindcss/vite'; import laravel from 'laravel-vite-plugin'; -import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [ + tailwindcss(), laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true, }), - tailwindcss(), ], server: { cors: true, + watch: { + ignored: [ + '**/storage/**', + '**/bootstrap/cache/**', + '**/.idea/**', + '**/.junie/**', + '**/.fleet/**', + '**/.vscode/**', + ], + }, }, });