Add rich Markdown normalization and paste handling.

- 🛠 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).
This commit is contained in:
HolgerHatGarKeineNode
2026-04-08 17:34:55 +01:00
parent 0e0738ff23
commit 04abf231bd
8 changed files with 579 additions and 8 deletions

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Console\Commands;
use App\Models\ProjectProposal;
use App\Support\RichTextMarkdownNormalizer;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('project-proposals:normalize-descriptions
{--dry-run : Show what would change without writing to the database}
{--id=* : Limit to specific proposal IDs}
{--show-diff : Print a short before/after preview for every change}')]
#[Description('Normalize project proposal descriptions so all rows contain clean HTML (converts legacy plain-text and mixed Markdown/HTML content).')]
class NormalizeProjectProposalDescriptions extends Command
{
public function handle(): int
{
$dryRun = (bool) $this->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('<fg=yellow>~</> #%d %s', $proposal->id, $proposal->name));
if ($showDiff) {
$this->line(' <fg=red>- '.$this->preview($original).'</>');
$this->line(' <fg=green>+ '.$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, '…');
}
}

View File

@@ -0,0 +1,238 @@
<?php
namespace App\Support;
use DOMDocument;
use Spatie\LaravelMarkdown\MarkdownRenderer;
/**
* Converts Tiptap/flux:editor HTML that contains literal Markdown syntax
* (e.g. from a plain-text paste) into proper structured HTML.
*
* The flux:editor wraps pasted plain text in <p> tags line by line, so
* pasted Markdown like `# Heading` ends up as `<p># Heading</p>` 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<int, string> 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<int, string>
*/
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 <p> 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('<?xml encoding="UTF-8"?><div>'.$html.'</div>', 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 <br> 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;
}
}

View File

@@ -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
</div>
</div>
</div>
@include('partials.markdown-paste-listener')
@else
<div class="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-9xl mx-auto">
<flux:callout variant="warning" icon="exclamation-circle">

View File

@@ -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
</div>
</div>
</div>
@include('partials.markdown-paste-listener')
@else
<div class="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-9xl mx-auto">
<flux:callout variant="warning" icon="exclamation-circle">

View File

@@ -137,10 +137,8 @@ new class extends Component {
<h1 class="text-2xl md:text-3xl text-zinc-800 dark:text-zinc-100 font-bold mb-2">
{{ $projectProposal->name }}
</h1>
<div class="prose">
<x-markdown>
{!! $projectProposal->description !!}
</x-markdown>
<div class="prose dark:prose-invert max-w-none break-words [&_code]:break-all [&_a]:break-all">
{!! $projectProposal->description !!}
</div>
</header>

View File

@@ -0,0 +1,97 @@
@script
<script>
(() => {
const MARKDOWN_PATTERNS = [
/^\s{0,3}#{1,6}\s+\S/m,
/^\s{0,3}[-*+]\s+\S/m,
/^\s{0,3}\d{1,9}[.)]\s+\S/m,
/^\s{0,3}>\s?/m,
/^\s{0,3}```/m,
/^\s*\S.*\n={3,}\s*$/m,
/^\s*\S.*\n-{3,}\s*$/m,
];
const looksLikeMarkdown = (text) => MARKDOWN_PATTERNS.some((re) => re.test(text));
const insertHtml = (editorEl, html) => {
const tiptap = editorEl.editor;
if (tiptap && tiptap.commands && typeof tiptap.commands.insertContent === 'function') {
tiptap.commands.insertContent(html);
return true;
}
const editable = editorEl.querySelector('[contenteditable="true"]');
if (!editable) {
return false;
}
if (editable !== document.activeElement) {
editable.focus();
}
return document.execCommand('insertHTML', false, html);
};
const attach = (editorEl) => {
if (editorEl.dataset.mdPasteBound === '1') {
return;
}
editorEl.dataset.mdPasteBound = '1';
editorEl.addEventListener(
'paste',
async (event) => {
const clipboard = event.clipboardData;
if (!clipboard) {
return;
}
const plain = clipboard.getData('text/plain');
if (!plain || !looksLikeMarkdown(plain)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
try {
const rendered = await $wire.convertMarkdownToHtml(plain);
if (!rendered) {
return;
}
insertHtml(editorEl, rendered);
} catch (error) {
console.error('Markdown paste conversion failed', error);
}
},
true,
);
};
const scan = (root) => {
(root || document).querySelectorAll('[data-flux-editor]').forEach(attach);
};
scan(document);
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== 1) {
return;
}
if (node.matches && node.matches('[data-flux-editor]')) {
attach(node);
}
scan(node);
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
})();
</script>
@endscript

View File

@@ -0,0 +1,111 @@
<?php
use App\Support\RichTextMarkdownNormalizer;
beforeEach(function () {
$this->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 = '<p># EINUNDZWANZIG STANDUP</p><p>## Wer ich bin</p><p>Regular text.</p>';
$result = $this->normalizer->normalize($html);
expect($result)->toContain('<h1');
expect($result)->toContain('EINUNDZWANZIG STANDUP');
expect($result)->toContain('<h2');
expect($result)->toContain('Wer ich bin');
expect($result)->toContain('Regular text.');
});
it('converts bullet list markdown wrapped in paragraph tags', function () {
$html = '<p>- first item</p><p>- second item</p><p>- third item</p>';
$result = $this->normalizer->normalize($html);
expect($result)->toContain('<ul>');
expect($result)->toContain('first item');
expect($result)->toContain('second item');
expect($result)->toContain('third item');
expect(substr_count($result, '<li>'))->toBe(3);
});
it('leaves structural html untouched when headings already exist', function () {
$html = '<h1>Real heading</h1><p># not a heading</p>';
expect($this->normalizer->normalize($html))->toBe($html);
});
it('leaves structural html untouched when list tags already exist', function () {
$html = '<ul><li>existing</li></ul><p>- not a list</p>';
expect($this->normalizer->normalize($html))->toBe($html);
});
it('leaves plain paragraph html untouched when it is not markdown', function () {
$html = '<p>Just some normal text without any markdown syntax.</p>';
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('<p>First paragraph with some text.</p>');
expect($result)->toContain('<p>Second paragraph follows.</p>');
});
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('<h2');
expect($result)->toContain('Heading Two');
expect($result)->toContain('<ul>');
expect($result)->toContain('<li>first</li>');
expect($result)->toContain('<img');
expect($result)->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 = '<p><strong>Antragsteller:</strong> DrShift — <code>user@example.com</code></p>'
.'<p><a href="https://example.com">Website</a></p>'
.'<p># Heading</p>';
$result = $this->normalizer->normalize($html);
expect($result)->toContain('<h1');
expect($result)->toContain('Heading');
expect($result)->toContain('<strong>Antragsteller:</strong>');
expect($result)->toContain('<code>user@example.com</code>');
expect($result)->toContain('<a href="https://example.com">Website</a>');
});
it('preserves images embedded via img tags', function () {
$html = '<p># Heading</p><p><img src="https://example.com/i.png" alt="caption"></p>';
$result = $this->normalizer->normalize($html);
expect($result)->toContain('<h1');
expect($result)->toContain('<img');
expect($result)->toContain('src="https://example.com/i.png"');
expect($result)->toContain('alt="caption"');
});

View File

@@ -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/**',
],
},
},
});