findAttachment($id, $articleId); $name = $attachment?->filename ?: "attachment-{$id}"; $alt = pathinfo($name, PATHINFO_FILENAME) ?: $name; if ($attachment && $this->isImage($attachment)) { return '!['.$this->escapeMdLabel($alt).']('.self::MD_PROTOCOL.$id.')'; } return '['.$this->escapeMdLabel($name).']('.self::MD_PROTOCOL.$id.')'; }, $content) ?? $content; } /** * Protect sablog attach tokens before HTML→Markdown conversion. * Tokens avoid underscores so html-to-markdown won't escape them. * * @return array{0: string, 1: array} */ public function protectLegacyTokensForHtmlConversion(string $html, ?int $articleId = null): array { $map = []; // Attribute values become markdown link/image destinations → restore as attach:ID only. $html = preg_replace_callback( '/\b(src|href)=([\'"])\[attach=(\d+)\]\2/i', function (array $matches) use (&$map, $articleId): string { $id = (int) $matches[3]; $role = 'dest'; $kind = strtolower($matches[1]) === 'src' ? 'image' : 'file'; $token = 'LBATTACHDEST'.$id.'X'; $map[$token] = ['id' => $id, 'kind' => $kind, 'role' => $role, 'article_id' => $articleId]; return $matches[1].'='.$matches[2].$token.$matches[2]; }, $html ) ?? $html; // Bare tokens become full markdown embeds after conversion. $html = preg_replace_callback(self::LEGACY_PATTERN, function (array $matches) use (&$map, $articleId): string { $id = (int) $matches[1]; $attachment = $this->findAttachment($id, $articleId); $kind = ($attachment && $this->isImage($attachment)) ? 'image' : 'file'; $token = 'LBATTACHBARE'.$id.'X'; $map[$token] = ['id' => $id, 'kind' => $kind, 'role' => 'bare', 'article_id' => $articleId]; return $token; }, $html) ?? $html; return [$html, $map]; } /** * @param array $map */ public function restoreProtectedTokensToMarkdown(string $markdown, array $map): string { foreach ($map as $token => $meta) { $id = (int) $meta['id']; if (($meta['role'] ?? '') === 'dest') { $markdown = str_replace($token, self::MD_PROTOCOL.$id, $markdown); continue; } $attachment = $this->findAttachment($id, $meta['article_id'] ?? null); $name = $attachment?->filename ?: "attachment-{$id}"; $alt = pathinfo($name, PATHINFO_FILENAME) ?: $name; $replacement = ($meta['kind'] ?? 'file') === 'image' ? '!['.$this->escapeMdLabel($alt).']('.self::MD_PROTOCOL.$id.')' : '['.$this->escapeMdLabel($name).']('.self::MD_PROTOCOL.$id.')'; $markdown = str_replace($token, $replacement, $markdown); } return $markdown; } /** * Render-time only: turn attach:123 into /attachment.php?id=123 for CommonMark. */ public function expandMarkdownAttachProtocol(string $markdown): string { return preg_replace_callback( '/\]\(attach:(\d+)\)/i', fn (array $matches): string => ']('.$this->publicEntryUrl((int) $matches[1]).')', $markdown ) ?? $markdown; } public function hydrateHtml(string $html, ?int $articleId = null): string { // 1) Attribute form: src/href="[attach=N]" (must run before bare token replace) $html = preg_replace_callback( '/\b(src|href)=([\'"])\[attach=(\d+)\]\2/i', function (array $matches): string { $attr = strtolower($matches[1]); $quote = $matches[2]; $id = (int) $matches[3]; return $attr.'='.$quote.$this->publicEntryUrl($id).$quote; }, $html ) ?? $html; // 2) Safety net for attach: protocol left in HTML attributes $html = preg_replace_callback( '/\b(href|src)=([\'"])attach:(\d+)\2/i', function (array $matches): string { $attr = strtolower($matches[1]); $quote = $matches[2]; $id = (int) $matches[3]; return $attr.'='.$quote.$this->publicEntryUrl($id).$quote; }, $html ) ?? $html; // 3) Bare legacy [attach=id] tokens $html = preg_replace_callback(self::LEGACY_PATTERN, function (array $matches) use ($articleId): string { $id = (int) $matches[1]; $attachment = $this->findAttachment($id, $articleId); $url = $this->publicEntryUrl($id); if ($attachment && $this->isImage($attachment)) { $alt = e(pathinfo($attachment->filename, PATHINFO_FILENAME) ?: $attachment->filename); return ''.$alt.''; } $label = e($attachment?->filename ?: "attachment-{$id}"); return ''.$label.''; }, $html) ?? $html; return $html; } public function publicEntryUrl(int $attachmentId): string { return url('/attachment.php?id='.$attachmentId); } protected function findAttachment(int $id, ?int $articleId = null): ?Attachment { try { return Cache::remember("attach-embed:{$id}", 60, function () use ($id) { return Attachment::query()->find($id); }); } catch (\Throwable) { return null; } } protected function isImage(Attachment $attachment): bool { if (is_string($attachment->mime) && str_starts_with($attachment->mime, 'image/')) { return true; } $ext = strtolower(pathinfo($attachment->filename, PATHINFO_EXTENSION)); return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'], true); } protected function escapeMdLabel(string $label): string { return str_replace(['[', ']', '!'], ['\\[', '\\]', '\\!'], $label); } }