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[^>]*>.*?\1>~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(
- '~~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 ,