From 533563321ea188735cffc50da0995b144460c74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Thu, 18 Dec 2025 18:18:44 +0100 Subject: [PATCH] Remove obsolete NZine setup --- .../nzine_magazine_publish_controller.js | 82 --- .../workflow_progress_controller.js | 198 ------- config/packages/workflow.yaml | 27 - migrations/Version20251218101349.php | 36 ++ src/Command/NzineSortArticlesCommand.php | 268 --------- src/Command/RssFetchCommand.php | 386 ------------- src/Controller/MagazineWizardController.php | 4 - src/Controller/NzineController.php | 510 ------------------ src/Entity/Nzine.php | 180 ------- src/Entity/NzineBot.php | 47 -- src/Examples/RssNzineSetupExample.php | 159 ------ src/Form/NzineBotType.php | 56 -- src/Form/NzineType.php | 30 -- src/Repository/NzineRepository.php | 72 --- src/Service/NzineCategoryIndexService.php | 311 ----------- src/Service/NzineWorkflowService.php | 147 ----- templates/components/UserMenu.html.twig | 5 - templates/nzine/list.html.twig | 57 -- templates/pages/nzine-editor.html.twig | 151 ------ templates/pages/nzine.html.twig | 16 - 20 files changed, 36 insertions(+), 2706 deletions(-) delete mode 100644 assets/controllers/publishing/nzine_magazine_publish_controller.js delete mode 100644 assets/controllers/publishing/workflow_progress_controller.js create mode 100644 migrations/Version20251218101349.php delete mode 100644 src/Command/NzineSortArticlesCommand.php delete mode 100644 src/Command/RssFetchCommand.php delete mode 100644 src/Controller/NzineController.php delete mode 100644 src/Entity/Nzine.php delete mode 100644 src/Entity/NzineBot.php delete mode 100644 src/Examples/RssNzineSetupExample.php delete mode 100644 src/Form/NzineBotType.php delete mode 100644 src/Form/NzineType.php delete mode 100644 src/Repository/NzineRepository.php delete mode 100644 src/Service/NzineCategoryIndexService.php delete mode 100644 src/Service/NzineWorkflowService.php delete mode 100644 templates/nzine/list.html.twig delete mode 100644 templates/pages/nzine-editor.html.twig delete mode 100644 templates/pages/nzine.html.twig diff --git a/assets/controllers/publishing/nzine_magazine_publish_controller.js b/assets/controllers/publishing/nzine_magazine_publish_controller.js deleted file mode 100644 index 434a236..0000000 --- a/assets/controllers/publishing/nzine_magazine_publish_controller.js +++ /dev/null @@ -1,82 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; - -export default class extends Controller { - static targets = ['status', 'publishButton']; - static values = { - categoryEvents: String, - magazineEvent: String, - publishUrl: String, - nzineSlug: String, - csrfToken: String - }; - - async publish(event) { - event.preventDefault(); - - if (!this.publishUrlValue || !this.csrfTokenValue || !this.nzineSlugValue) { - this.showError('Missing configuration'); - return; - } - - this.publishButtonTarget.disabled = true; - - try { - const categoryEvents = JSON.parse(this.categoryEventsValue || '[]'); - const magazineEvent = JSON.parse(this.magazineEventValue || '{}'); - - this.showStatus('Publishing magazine and categories...'); - - const response = await fetch(this.publishUrlValue, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': this.csrfTokenValue, - 'X-Requested-With': 'XMLHttpRequest' - }, - body: JSON.stringify({ - nzineSlug: this.nzineSlugValue, - categoryEvents: categoryEvents, - magazineEvent: magazineEvent - }) - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error || `HTTP ${response.status}`); - } - - const result = await response.json(); - this.showSuccess(result.message || 'Magazine published successfully!'); - - // Redirect to home or magazine page after a short delay - setTimeout(() => { - window.location.href = '/'; - }, 2000); - - } catch (e) { - console.error(e); - this.showError(e.message || 'Publish failed'); - } finally { - this.publishButtonTarget.disabled = false; - } - } - - showStatus(message) { - if (this.hasStatusTarget) { - this.statusTarget.innerHTML = `
${message}
`; - } - } - - showSuccess(message) { - if (this.hasStatusTarget) { - this.statusTarget.innerHTML = `
${message}
`; - } - } - - showError(message) { - if (this.hasStatusTarget) { - this.statusTarget.innerHTML = `
${message}
`; - } - } -} - diff --git a/assets/controllers/publishing/workflow_progress_controller.js b/assets/controllers/publishing/workflow_progress_controller.js deleted file mode 100644 index c715034..0000000 --- a/assets/controllers/publishing/workflow_progress_controller.js +++ /dev/null @@ -1,198 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; - -/** - * Workflow Progress Bar Controller - * - * Handles animated progress bar with color transitions and status updates. - * - * Usage: - *
- *
- */ -export default class extends Controller { - static values = { - percentage: { type: Number, default: 0 }, - status: { type: String, default: 'empty' }, - color: { type: String, default: 'secondary' }, - animated: { type: Boolean, default: true } - } - - static targets = ['bar', 'badge', 'statusText', 'nextSteps'] - - connect() { - this.updateProgress(); - } - - percentageValueChanged() { - this.updateProgress(); - } - - statusValueChanged() { - this.updateStatusDisplay(); - } - - colorValueChanged() { - this.updateBarColor(); - } - - updateProgress() { - if (!this.hasBarTarget) return; - - const percentage = this.percentageValue; - - if (this.animatedValue) { - // Smooth animation - this.animateProgressBar(percentage); - } else { - // Instant update - this.barTarget.style.width = `${percentage}%`; - this.barTarget.setAttribute('aria-valuenow', percentage); - } - - // Update accessibility - this.updateAriaLabel(); - } - - animateProgressBar(targetPercentage) { - const currentPercentage = parseInt(this.barTarget.style.width) || 0; - const duration = 600; // ms - const steps = 30; - const increment = (targetPercentage - currentPercentage) / steps; - const stepDuration = duration / steps; - - let currentStep = 0; - - const animate = () => { - if (currentStep >= steps) { - this.barTarget.style.width = `${targetPercentage}%`; - this.barTarget.setAttribute('aria-valuenow', targetPercentage); - return; - } - - const newPercentage = currentPercentage + (increment * currentStep); - this.barTarget.style.width = `${newPercentage}%`; - this.barTarget.setAttribute('aria-valuenow', Math.round(newPercentage)); - - currentStep++; - requestAnimationFrame(() => { - setTimeout(animate, stepDuration); - }); - }; - - animate(); - } - - updateBarColor() { - if (!this.hasBarTarget) return; - - const colorClasses = [ - 'bg-secondary', 'bg-info', 'bg-primary', - 'bg-success', 'bg-warning', 'bg-danger' - ]; - - // Remove all color classes - colorClasses.forEach(cls => this.barTarget.classList.remove(cls)); - - // Add new color class - this.barTarget.classList.add(`bg-${this.colorValue}`); - } - - updateStatusDisplay() { - if (this.hasBadgeTarget) { - const statusMessages = this.getStatusMessage(this.statusValue); - this.badgeTarget.textContent = statusMessages.short; - } - - if (this.hasStatusTextTarget) { - const statusMessages = this.getStatusMessage(this.statusValue); - this.statusTextTarget.textContent = statusMessages.long; - } - } - - updateAriaLabel() { - if (!this.hasBarTarget) return; - - const percentage = this.percentageValue; - const statusMessages = this.getStatusMessage(this.statusValue); - const label = `${statusMessages.short}: ${percentage}% complete`; - - this.barTarget.setAttribute('aria-label', label); - } - - getStatusMessage(status) { - const messages = { - 'empty': { - short: 'Not started', - long: 'Reading list not started yet' - }, - 'draft': { - short: 'Draft created', - long: 'Draft created, add content to continue' - }, - 'has_metadata': { - short: 'Title and summary added', - long: 'Metadata complete, add articles next' - }, - 'has_articles': { - short: 'Articles added', - long: 'Articles added, checking requirements' - }, - 'ready_for_review': { - short: 'Ready to publish', - long: 'Your reading list is ready to publish' - }, - 'publishing': { - short: 'Publishing...', - long: 'Publishing to Nostr, please wait' - }, - 'published': { - short: 'Published', - long: 'Successfully published to Nostr' - }, - 'editing': { - short: 'Editing', - long: 'Editing published reading list' - } - }; - - return messages[status] || messages['empty']; - } - - // Public methods that can be called from other controllers - setPercentage(percentage) { - this.percentageValue = percentage; - } - - setStatus(status) { - this.statusValue = status; - } - - setColor(color) { - this.colorValue = color; - } - - pulse() { - if (!this.hasBarTarget) return; - - this.barTarget.classList.add('workflow-progress-pulse'); - setTimeout(() => { - this.barTarget.classList.remove('workflow-progress-pulse'); - }, 1000); - } - - celebrate() { - if (!this.hasBarTarget) return; - - // Add celebration animation when reaching 100% - if (this.percentageValue === 100) { - this.barTarget.classList.add('workflow-progress-celebrate'); - setTimeout(() => { - this.barTarget.classList.remove('workflow-progress-celebrate'); - }, 2000); - } - } -} - diff --git a/config/packages/workflow.yaml b/config/packages/workflow.yaml index 8e48179..dd2245a 100644 --- a/config/packages/workflow.yaml +++ b/config/packages/workflow.yaml @@ -26,33 +26,6 @@ framework: edit: from: published to: edited - nzine_workflow: - type: state_machine - marking_store: - type: method - property: state - supports: - - App\Entity\Nzine - initial_marking: draft - places: - - draft - - profile_created - - main_index_created - - nested_indices_created - - published - transitions: - create_profile: - from: draft - to: profile_created - create_main_index: - from: profile_created - to: main_index_created - create_nested_indices: - from: main_index_created - to: nested_indices_created - publish: - from: nested_indices_created - to: published reading_list_workflow: type: state_machine audit_trail: diff --git a/migrations/Version20251218101349.php b/migrations/Version20251218101349.php new file mode 100644 index 0000000..8e87f57 --- /dev/null +++ b/migrations/Version20251218101349.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE nzine DROP CONSTRAINT fk_65025d9871fd5427'); + $this->addSql('DROP TABLE nzine'); + $this->addSql('DROP TABLE nzine_bot'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE TABLE nzine (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, npub VARCHAR(255) NOT NULL, main_categories JSON NOT NULL, lists JSON DEFAULT NULL, editor VARCHAR(255) DEFAULT NULL, slug TEXT DEFAULT NULL, state VARCHAR(255) NOT NULL, nzine_bot_id INT DEFAULT NULL, feed_url TEXT DEFAULT NULL, last_fetched_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, feed_config JSON DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_65025d9871fd5427 ON nzine (nzine_bot_id)'); + $this->addSql('CREATE TABLE nzine_bot (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, encrypted_nsec VARCHAR(255) NOT NULL, PRIMARY KEY (id))'); + $this->addSql('ALTER TABLE nzine ADD CONSTRAINT fk_65025d9871fd5427 FOREIGN KEY (nzine_bot_id) REFERENCES nzine_bot (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } +} diff --git a/src/Command/NzineSortArticlesCommand.php b/src/Command/NzineSortArticlesCommand.php deleted file mode 100644 index da60bf1..0000000 --- a/src/Command/NzineSortArticlesCommand.php +++ /dev/null @@ -1,268 +0,0 @@ -nzineRepository->findOneBy([]); - if (!$nzine) { - $io->error('No NZine entity found.'); - return Command::FAILURE; - } - - /** @var NzineBot $bot */ - $bot = $nzine->getNzineBot(); - $bot->setEncryptionService($this->encryptionService); - - $key = new Key(); - $signer = new Sign(); - $publicKey = strtolower($key->getPublicKey($bot->getNsec())); // hex - - /** @var Article[] $articles */ - $articles = $this->articleRepository->findBy(['pubkey' => $publicKey]); - $io->writeln('Articles for bot: ' . count($articles)); - - /** @var DbEvent[] $indexes */ - $indexes = $this->em->getRepository(DbEvent::class)->findBy([ - 'pubkey' => $publicKey, - 'kind' => KindsEnum::PUBLICATION_INDEX, - ]); - $io->writeln('Found ' . count($indexes) . ' existing indexes for bot ' . $publicKey); - - if (!$indexes) { - $io->warning('No existing publication indexes found; nothing to update.'); - return Command::SUCCESS; - } - - // newest index per d-tag (slug) - $newestIndexBySlug = []; - foreach ($indexes as $idx) { - $d = $this->firstTagValue($idx->getTags() ?? [], 'd'); - if ($d === null) continue; - if (!isset($newestIndexBySlug[$d]) || $idx->getCreatedAt() > $newestIndexBySlug[$d]->getCreatedAt()) { - $newestIndexBySlug[$d] = $idx; - } - } - - $mainCategories = $nzine->getMainCategories() ?? []; - $totalUpdated = 0; - - foreach ($mainCategories as $category) { - $slug = (string)($category['slug'] ?? ''); - if ($slug === '') continue; - - $index = $newestIndexBySlug[$slug] ?? null; - if (!$index) { - $io->writeln(" - Skip category '{$slug}': no index found for this slug."); - continue; - } - - $tags = $index->getTags() ?? []; - - // topics tracked by this index (t-tags) - $trackedTopics = array_values(array_unique(array_filter(array_map( - fn($t) => $this->normTag($t), - $this->allTagValues($tags, 't') - )))); - if (!$trackedTopics) { - $io->writeln(" - Index d='{$slug}': no tracked 't' tags, skipping."); - continue; - } - - // existing a-tags for dedupe - $existingA = []; - foreach ($tags as $t) { - if (($t[0] ?? null) === 'a' && isset($t[1])) { - $existingA[strtolower($t[1])] = true; - } - } - - $added = 0; - - foreach ($articles as $article) { - if (strtolower($article->getPubkey()) !== $publicKey) continue; - - $slugArticle = (string)$article->getSlug(); - if ($slugArticle === '') continue; - - $articleTopics = $article->getTopics() ?? []; - if (!$articleTopics) continue; - - if (!$this->intersects($articleTopics, $trackedTopics)) continue; - - $coord = sprintf('%s:%s:%s', KindsEnum::LONGFORM->value, $publicKey, $slugArticle); - $coordKey = strtolower($coord); - if (!isset($existingA[$coordKey])) { - $tags[] = ['a', $coord]; - $existingA[$coordKey] = true; - $added++; - } - } - - if ($added > 0) { - $tags = $this->sortedATagsLast($tags); - $index->setTags($tags); - - // ---- SIGN USING SWENTEL EVENT ---- - $wire = $this->toWireEvent($index, $publicKey); - $wire->setTags($tags); - $signer->signEvent($wire, $bot->getNsec()); - $this->applySignedWireToEntity($wire, $index); - // ----------------------------------- - - $this->em->persist($index); - $io->writeln(" + Updated index d='{$slug}': added {$added} article(s)."); - $totalUpdated++; - } else { - $io->writeln(" - Index d='{$slug}': no new matches."); - } - } - - if ($totalUpdated > 0) { - $this->em->flush(); - } - - $io->success("Done. Updated {$totalUpdated} index(es)."); - return Command::SUCCESS; - } - - private function firstTagValue(array $tags, string $name): ?string - { - foreach ($tags as $t) { - if (($t[0] ?? null) === $name && isset($t[1])) { - return (string)$t[1]; - } - } - return null; - } - - private function allTagValues(array $tags, string $name): array - { - $out = []; - foreach ($tags as $t) { - if (($t[0] ?? null) === $name && isset($t[1])) { - $out[] = (string)$t[1]; - } - } - return $out; - } - - private function normTag(?string $t): string - { - $t = trim((string)$t); - if ($t !== '' && $t[0] === '#') $t = substr($t, 1); - return mb_strtolower($t); - } - - private function intersects(array $a, array $b): bool - { - if (!$a || !$b) return false; - $set = array_fill_keys($b, true); - foreach ($a as $x) if (isset($set[$x])) return true; - return false; - } - - private function sortedATagsLast(array $tags): array - { - $aTags = []; - $other = []; - foreach ($tags as $t) { - if (($t[0] ?? null) === 'a' && isset($t[1])) $aTags[] = $t; - else $other[] = $t; - } - usort($aTags, fn($x, $y) => strcmp(strtolower($x[1]), strtolower($y[1]))); - return array_merge($other, $aTags); - } - - /** - * Build a swentel wire event from your DB entity so we can sign it. - */ - private function toWireEvent(DbEvent $e, string $pubkey): WireEvent - { - $w = new WireEvent(); - $w->setKind($e->getKind()); - $createdAt = $e->getCreatedAt(); - // accept int or DateTimeInterface - if ($createdAt instanceof \DateTimeInterface) { - $w->setCreatedAt($createdAt->getTimestamp()); - } else { - $w->setCreatedAt((int)$createdAt ?: time()); - } - $w->setContent((string)($e->getContent() ?? '')); - $w->setTags($e->getTags() ?? []); - $w->setPublicKey($pubkey); // ensure pubkey is set for id computation - return $w; - } - - /** - * Copy signature/id (and any normalized fields) back to your entity. - */ - private function applySignedWireToEntity(WireEvent $w, DbEvent $e): void - { - if (method_exists($e, 'setId') && $w->getId()) { - $e->setId($w->getId()); - } - if (method_exists($e, 'setSig') && $w->getSignature()) { - $e->setSig($w->getSignature()); - } - if (method_exists($e, 'setPubkey') && $w->getPublicKey()) { - $e->setPubkey($w->getPublicKey()); - } - // keep tags/content in sync (in case swentel normalized) - if (method_exists($e, 'setTags')) { - $e->setTags($w->getTags()); - } - if (method_exists($e, 'setContent')) { - $e->setContent($w->getContent()); - } - if (method_exists($e, 'setCreatedAt') && is_int($w->getCreatedAt())) { - // optional: keep your entity’s createdAt as int or DateTime, depending on your schema - try { - $e->setCreatedAt($w->getCreatedAt()); - } catch (\TypeError $t) { - // if your setter expects DateTimeImmutable: - if ($w->getCreatedAt()) { - $e->setCreatedAt((new \DateTimeImmutable())->setTimestamp($w->getCreatedAt())->getTimestamp()); - } - } - } - // also ensure kind stays set - if (method_exists($e, 'setKind')) { - $e->setKind($w->getKind()); - } - } -} diff --git a/src/Command/RssFetchCommand.php b/src/Command/RssFetchCommand.php deleted file mode 100644 index 2e7dfe3..0000000 --- a/src/Command/RssFetchCommand.php +++ /dev/null @@ -1,386 +0,0 @@ -nzineRepository->findAll(); - foreach ($nzines as $nzine) { - if (!$nzine->getFeedUrl()) { - continue; - } - - /** @var NzineBot $bot */ - $bot = $nzine->getNzineBot(); - $bot->setEncryptionService($this->encryptionService); - - $key = new Key(); - $npub = $key->getPublicKey($bot->getNsec()); - $articles = $this->entityManager->getRepository(Article::class)->findBy(['pubkey' => $npub]); - $io->writeln('Found ' . count($articles) . ' existing articles for bot ' . $npub); - - $io->section('Fetching RSS for: ' . $nzine->getFeedUrl()); - - try { - $feed = $this->rssFeedService->fetchFeed($nzine->getFeedUrl()); - } catch (\Throwable $e) { - $io->warning('Failed to fetch ' . $nzine->getFeedUrl() . ': ' . $e->getMessage()); - continue; - } - - foreach ($feed['items'] as $item) { - try { - $event = new Event(); - $event->setKind(30023); // NIP-23 Long-form content - - // created_at — use parsed pubDate (timestamp int) or now - $createdAt = isset($item['pubDate']) && is_numeric($item['pubDate']) - ? (int)$item['pubDate'] - : time(); - $event->setCreatedAt($createdAt); - - // slug (NIP-33 'd' tag) — stable per source item - $base = trim(($nzine->getSlug() ?? 'nzine') . '-' . ($item['title'] ?? '')); - $slug = (string) $slugger->slug($base)->lower(); - - // HTML → Markdown - $raw = trim($item['content'] ?? '') ?: trim($item['description'] ?? ''); - $rawHtml = $this->normalizeWeirdHtml($raw); - $cleanHtml = $this->sanitizeHtml($rawHtml); - $markdown = $this->htmlToMarkdown($cleanHtml); - $event->setContent($markdown); - - // Tags - $tags = [ - ['title', $this->safeStr($item['title'] ?? '')], - ['d', $slug], - ['source', $this->safeStr($item['link'] ?? '')], - ]; - - // summary (short description) - $summary = $this->ellipsis($this->plainText($item['description'] ?? ''), 280); - if ($summary !== '') { - $tags[] = ['summary', $summary]; - } - - // image - if (!empty($item['image'])) { - $tags[] = ['image', $this->safeStr($item['image'])]; - } else { - // try to sniff first from content if media tag was missing - if (preg_match('~]+src="([^"]+)"~i', $rawHtml, $m)) { - $tags[] = ['image', $m[1]]; - } - } - - // categories → "t" tags - if (!empty($item['categories']) && is_array($item['categories'])) { - foreach ($item['categories'] as $category) { - $cat = trim((string)$category); - if ($cat !== '') { - $event->addTag(['t', $cat]); - } - } - } - - $event->setTags($tags); - - // Sign event - $signer = new Sign(); - $signer->signEvent($event, $bot->getNsec()); - - // Publish (add/adjust relays as you like) - try { - $this->nostrClient->publishEvent($event, [ - 'wss://purplepag.es', - 'wss://relay.damus.io', - 'wss://nos.lol', - ]); - $io->writeln('Published long-form event: ' . ($item['title'] ?? '(no title)')); - } catch (\Throwable $e) { - $io->warning('Publish failed: ' . $e->getMessage()); - } - - // Persist locally - $article = $this->factory->createFromLongFormContentEvent((object)$event->toArray()); - $this->entityManager->persist($article); - - } catch (\Throwable $e) { - // keep going on item errors - $io->warning('Item failed: ' . ($item['title'] ?? '(no title)') . ' — ' . $e->getMessage()); - } - } - - $this->entityManager->flush(); - $io->success('RSS fetch complete for: ' . $nzine->getFeedUrl()); - - // --- Update bot profile (kind 0) using feed metadata --- - $feedMeta = $feed['feed'] ?? null; - if ($feedMeta) { - $profile = [ - 'name' => $feedMeta['title'] ?? $nzine->getTitle(), - 'about' => $feedMeta['description'] ?? '', - 'picture' => $feedMeta['image'] ?? null, - 'website' => $feedMeta['link'] ?? null, - ]; - $p = new Event(); - $p->setKind(0); - $p->setCreatedAt(time()); - $p->setContent(json_encode($profile, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); - $signer = new Sign(); - $signer->signEvent($p, $bot->getNsec()); - try { - $this->nostrClient->publishEvent($p, ['wss://purplepag.es']); - $io->success('Published bot profile (kind 0) with feed metadata'); - } catch (\Throwable $e) { - $io->warning('Failed to publish bot profile event: ' . $e->getMessage()); - } - } - } - - return Command::SUCCESS; - } - - /** -------- Helpers: HTML prep + converter + small utils -------- */ - - private function normalizeWeirdHtml(string $html): string - { - // 1) Unwrap Ghost "HTML cards": keep only the content, drop / wrappers and scripts - $html = preg_replace_callback('/.*?/si', function ($m) { - $block = $m[0]; - // Extract inner … if present - if (preg_match('/]*>(.*?)<\/body>/si', $block, $mm)) { - $inner = $mm[1]; - } else { - // No explicit body; just strip the markers - $inner = preg_replace('//', '', $block); - } - return $inner; - }, $html); - - // 2) Nuke any remaining document wrappers that would cut DOM parsing short - $html = preg_replace([ - '/<\/?html[^>]*>/i', - '/<\/?body[^>]*>/i', - '/]*>.*?<\/head>/si', - ], '', $html); - - dump($html); - - return $html; - } - - - private function sanitizeHtml(string $html): string - { - if ($html === '') return $html; - - // 0) quick pre-clean: kill scripts/styles early to avoid DOM bloat - $html = preg_replace('~<(script|style)\b[^>]*>.*?~is', '', $html); - $html = preg_replace('~~s', '', $html); // comments - - // 1) Normalize weird widgets and wrappers BEFORE DOM parse - // lightning-widget → simple text - $html = preg_replace_callback( - '~]*\bto="([^"]+)"[^>]*>.*?~is', - fn($m) => '

⚡ Tips: ' . htmlspecialchars($m[1]) . '

', - $html - ); - // Ghost/Koenig wrappers: keep useful inner content - $html = preg_replace('~]*\bkg-image-card\b[^>]*>\s*(]+>)\s*~i', '$1', $html); - $html = preg_replace('~]*\bkg-callout-card\b[^>]*>(.*?)~is', '
$1
', $html); - // YouTube iframes → links - $html = preg_replace_callback( - '~]+src="https?://www\.youtube\.com/embed/([A-Za-z0-9_\-]+)[^"]*"[^>]*>~i', - fn($m) => '

Watch on YouTube

', - $html - ); - - // 2) Try to pretty up malformed markup via Tidy (if available) - if (function_exists('tidy_parse_string')) { - try { - $tidy = tidy_parse_string($html, [ - 'clean' => true, - 'output-xhtml' => true, - 'show-body-only' => false, - 'wrap' => 0, - 'drop-empty-paras' => true, - 'merge-divs' => true, - 'merge-spans' => true, - 'numeric-entities' => false, - 'quote-ampersand' => true, - ], 'utf8'); - $tidy->cleanRepair(); - $html = (string)$tidy; - } catch (\Throwable $e) { - // ignore tidy failures - } - } - - // 3) DOM sanitize: remove junk, unwrap html/body/head, allowlist elements/attrs - $dom = new \DOMDocument('1.0', 'UTF-8'); - libxml_use_internal_errors(true); - $loaded = $dom->loadHTML( - // force UTF-8 meta so DOMDocument doesn't mangle - ''.$html, - LIBXML_NOWARNING | LIBXML_NOERROR - ); - libxml_clear_errors(); - if (!$loaded) { - // fallback: as-is minus tags we already stripped - return $html; - } - - $xpath = new \DOMXPath($dom); - - // Remove ,