fix: release QA hardening across processing, media, security, and CI gates (#649)

A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
This commit is contained in:
SnapOtter
2026-07-27 15:37:30 +08:00
committed by GitHub
parent bc32f86a07
commit d10d0f544f
855 changed files with 54564 additions and 13092 deletions
@@ -1,5 +1,6 @@
---
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes frontmatter imports.
import { TOOLS, toolSection } from "@snapotter/shared";
import * as lucideIcons from "lucide";
import { t } from "@/i18n";
@@ -1,5 +1,6 @@
---
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes frontmatter imports.
import { t } from "@/i18n";
import { localizeHref } from "@/lib/i18n-page";
@@ -104,7 +104,7 @@ const aiPillColors = [
<svg class="h-4 w-4 shrink-0 text-danger" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<span class="text-xs text-danger">{t(locale, "home.features.f1.noExternal")}</span>
<span class="text-xs text-danger-ink">{t(locale, "home.features.f1.noExternal")}</span>
</div>
</div>
</div>
+13 -4
View File
@@ -79,7 +79,8 @@ const columns = [
<!-- Brand column -->
<div class="lg:col-span-1">
<div class="flex items-center gap-2.5">
<img src="/logo.png" alt="SnapOtter" class="h-7 w-7" width="28" height="28" />
<!-- Decorative: the adjacent span already says SnapOtter. -->
<img src="/logo.png" alt="" class="h-7 w-7" width="28" height="28" />
<span class="font-display text-lg font-bold">SnapOtter</span>
</div>
<p class="mt-3 text-sm leading-relaxed text-muted">
@@ -119,7 +120,11 @@ const columns = [
<!-- Link columns -->
{columns.map((col) => (
<div>
<h4 class="text-sm font-semibold text-foreground">{col.title}</h4>
{/* h2, not h4. The footer is a contentinfo landmark and every page has one
h1, so starting its headings at level 2 never skips. As an h4 it skipped
a level on every page in the site: h2 to h4 on the content pages and h1
to h4 on the tool section pages. The size comes from the class either way. */}
<h2 class="text-sm font-semibold text-foreground">{col.title}</h2>
<ul class="mt-4 space-y-2.5">
{col.links.map((link) => (
<li>
@@ -137,7 +142,11 @@ const columns = [
))}
</div>
<!-- Featured-on badges -->
<!-- Featured-on badges. All three images are served from our own origin: a
hot-linked badge would hand the visitor's IP, Referer, and UA to the
badge host on every page view, and PostHog and Sentry are the only
destinations this project talks to. -->
<div class="section-divider mx-auto mt-12 max-w-6xl"></div>
<div class="mx-auto flex max-w-6xl flex-wrap items-center gap-5 pt-8">
<a href="https://twelve.tools" target="_blank" rel="noopener noreferrer" aria-label="Featured on Twelve Tools">
@@ -147,7 +156,7 @@ const columns = [
<img src="/badges/wired-business.svg" alt="Featured on Wired Business" width="200" height="54" class="h-9 w-auto" />
</a>
<a href="https://lift-off.sh/p/snapotter" target="_blank" rel="noopener noreferrer" aria-label="Launched on LiftOff">
<img src="https://lift-off.sh/images/badges/badgeLaunchedLightBAR.webp" alt="Launched on LiftOff" width="360" height="120" class="h-9 w-auto" />
<img src="/badges/lift-off-launched.webp" alt="Launched on LiftOff" width="360" height="120" class="h-9 w-auto" />
</a>
</div>
@@ -1,5 +1,6 @@
---
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes frontmatter imports.
import { t } from "@/i18n";
interface Props {
@@ -117,7 +118,10 @@ const DOCKER_CMD =
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
const legacyCopy = Reflect.get(document, "execCommand") as
| ((commandId: string) => boolean)
| undefined;
legacyCopy?.call(document, "copy");
} catch {
// Nothing else to try; the icon still flips so the user can copy manually.
}
+8 -1
View File
@@ -4,7 +4,7 @@
import { TOOLS, toolSection } from "@snapotter/shared";
import * as lucideIcons from "lucide";
import { t } from "@/i18n";
import { enOnlyHref, localizeHref } from "@/lib/i18n-page";
import { enOnlyHref } from "@/lib/i18n-page";
import { loadToolStrings } from "@/lib/tool-strings";
interface Props {
@@ -40,9 +40,16 @@ function renderIcon(iconName: string): string {
<div class="hero-search animate-fade-up relative z-30 mx-auto mt-8 max-w-xl" style="animation-delay: 0.18s;">
<div class="relative">
<svg class="pointer-events-none absolute top-1/2 left-5 h-5 w-5 -translate-y-1/2 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<!-- role="combobox": aria-expanded and aria-controls are not allowed on a plain
textbox, which axe reports as a critical aria-allowed-attr failure on every
home page. The role is also what this control already is, a text field that
opens a result list below it. -->
<input
type="text"
id="hero-tool-search"
role="combobox"
aria-autocomplete="list"
aria-haspopup="listbox"
autocomplete="off"
placeholder={t(locale, "home.hero.searchPlaceholder")}
class="w-full rounded-2xl border border-border bg-surface py-4 pe-4 ps-12 text-base shadow-sm outline-none transition-all placeholder:text-muted focus:border-primary-ink focus:shadow-md"
+2 -1
View File
@@ -42,7 +42,8 @@ const links: NavItem[] = [
<div class="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
<!-- Logo -->
<a href={localizeHref(locale, "/")} class="flex shrink-0 items-center gap-2.5">
<img src="/logo.png" alt="SnapOtter" class="h-8 w-8" width="32" height="32" />
<!-- Decorative: the adjacent span already gives the link its accessible name. -->
<img src="/logo.png" alt="" class="h-8 w-8" width="32" height="32" />
<span class="font-display text-lg font-bold tracking-tight">SnapOtter</span>
</a>
@@ -1,5 +1,6 @@
---
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { t } from "@/i18n";
import SectionHeading from "./SectionHeading.astro";
@@ -79,7 +79,12 @@ const blocks = [
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch {}
try {
const legacyCopy = Reflect.get(document, "execCommand") as
| ((commandId: string) => boolean)
| undefined;
legacyCopy?.call(document, "copy");
} catch {}
document.body.removeChild(ta);
}
flip(true);
@@ -14,7 +14,7 @@ const { title, body } = Astro.props;
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<div>
<p class="text-sm font-semibold text-danger">{title}</p>
<p class="text-sm font-semibold text-danger-ink">{title}</p>
<p class="mt-2 text-sm leading-relaxed text-muted">{body}</p>
</div>
</div>
@@ -250,6 +250,7 @@ const safeStringsJson = JSON.stringify(toolGridStrings).replace(/</g, "\\u003c")
<button
type="button"
data-modality={modality.id}
aria-label={`${modality.label} ${t(locale, "home.categoryCards.toolCount", { count: modality.count })}`}
aria-pressed={modality.id === "all" ? "true" : "false"}
class:list={[
"tool-command-modality rounded-md px-3 py-2 text-sm font-semibold transition-colors",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "ابحث في الأدوات",
"home.hero.subtitle": "حوّل واضغط وطبّق OCR ودوّن نصوصًا وأزل البيانات الوصفية وشغّل ذكاءً اصطناعيًا محليًا على الصور والفيديو والصوت وملفات PDF والمستندات. عبر واجهة استخدام وREST API وخطوط معالجة، على بنية تحتية تملكها.",
"home.hero.title": "معالجة ملفات خاصة للبيانات التي لا يمكن أن تغادر شبكتك.",
"home.metaDescription": "بنية معالجة ملفات مفتوحة المصدر وذاتية الاستضافة. حوّل واضغط وطبّق OCR ودوّن نصوصًا وشغّل ذكاءً اصطناعيًا محليًا على الصور والفيديو والصوت وملفات PDF والمستندات، عبر واجهة استخدام وREST API وخطوط معالجة. البديل الخاص وذاتي الاستضافة لـ CloudConvert وSmallpdf وTinyPNG.",
"home.metaTitle": "بنية معالجة الملفات ذاتية الاستضافة | SnapOtter",
"home.openSource.discord": "انضم إلى Discord",
"home.openSource.sponsor": "كن راعيًا",
"home.openSource.star": "ضع نجمة على GitHub",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Tools suchen",
"home.hero.subtitle": "Konvertieren, komprimieren, OCR, transkribieren, Metadaten entfernen und lokale KI ausführen für Bild, Video, Audio, PDF und Dokumente. Über eine UI, REST API und Pipelines, auf einer Infrastruktur, die dir gehört.",
"home.hero.title": "Private Dateiverarbeitung für Daten, die dein Netzwerk nicht verlassen dürfen.",
"home.metaDescription": "Open-Source-Infrastruktur für die Dateiverarbeitung, selbst gehostet. Konvertieren, komprimieren, OCR, transkribieren und lokale KI ausführen für Bild, Video, Audio, PDF und Dokumente, über eine UI, REST API und Pipelines. Die private, selbst gehostete Alternative zu CloudConvert, Smallpdf und TinyPNG.",
"home.metaTitle": "Self-Hosted-Infrastruktur für Dateiverarbeitung | SnapOtter",
"home.openSource.discord": "Discord beitreten",
"home.openSource.sponsor": "Sponsern",
"home.openSource.star": "Auf GitHub mit Stern versehen",
+2
View File
@@ -28,6 +28,8 @@
"banner.dismiss": "Dismiss",
"faq.metaTitle": "FAQ | SnapOtter",
"faq.metaDescription": "Frequently asked questions about SnapOtter, self-hosted file-processing infrastructure.",
"home.metaTitle": "SnapOtter | Self-Hosted File-Processing Infrastructure",
"home.metaDescription": "Open-source, self-hosted file-processing infrastructure. Convert, compress, OCR, transcribe, and run local AI across image, video, audio, PDF, and documents, through a UI, REST API, and pipelines. The private, self-hosted alternative to CloudConvert, Smallpdf, and TinyPNG.",
"faq.title": "Frequently Asked Questions",
"faq.subtitle": "Everything you need to know about SnapOtter.",
"faq.stillHave.title": "Still have questions?",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Buscar herramientas",
"home.hero.subtitle": "Convierte, comprime, aplica OCR, transcribe, elimina metadatos y ejecuta IA local en imagen, vídeo, audio, PDF y documentos. A través de una interfaz, una REST API y pipelines, en la infraestructura que tú controlas.",
"home.hero.title": "Procesamiento de archivos privado para datos que no pueden salir de tu red.",
"home.metaDescription": "Infraestructura autoalojada y de código abierto para el procesamiento de archivos. Convierte, comprime, aplica OCR, transcribe y ejecuta IA local en imagen, vídeo, audio, PDF y documentos, a través de una interfaz, una REST API y pipelines. La alternativa privada y autoalojada a CloudConvert, Smallpdf y TinyPNG.",
"home.metaTitle": "Procesamiento de archivos autoalojado | SnapOtter",
"home.openSource.discord": "Únete a Discord",
"home.openSource.sponsor": "Patrocinar",
"home.openSource.star": "Dale una estrella en GitHub",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Rechercher des outils",
"home.hero.subtitle": "Convertissez, compressez, appliquez l'OCR, transcrivez, supprimez les métadonnées et exécutez l'IA en local sur vos images, vidéos, fichiers audio, PDF et documents. Via une interface, une API REST et des pipelines, sur une infrastructure qui vous appartient.",
"home.hero.title": "Traitement de fichiers privé pour les données qui ne peuvent pas quitter votre réseau.",
"home.metaDescription": "Infrastructure open source et auto-hébergée de traitement de fichiers. Convertissez, compressez, appliquez l'OCR, transcrivez et exécutez l'IA en local sur vos images, vidéos, fichiers audio, PDF et documents, via une interface, une API REST et des pipelines. L'alternative privée et auto-hébergée à CloudConvert, Smallpdf et TinyPNG.",
"home.metaTitle": "Traitement de fichiers auto-hébergé | SnapOtter",
"home.openSource.discord": "Rejoindre Discord",
"home.openSource.sponsor": "Devenir sponsor",
"home.openSource.star": "Ajouter une étoile sur GitHub",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "टूल्स खोजें",
"home.hero.subtitle": "इमेज, वीडियो, ऑडियो, PDF और डॉक्युमेंट में कन्वर्ट करें, कंप्रेस करें, OCR चलाएँ, ट्रांसक्राइब करें, मेटाडेटा हटाएँ और लोकल AI चलाएँ। एक UI, REST API और पाइपलाइन के ज़रिए, उस इंफ्रास्ट्रक्चर पर जो आपका अपना है।",
"home.hero.title": "उस डेटा के लिए निजी फ़ाइल प्रोसेसिंग जो आपके नेटवर्क से बाहर नहीं जा सकता।",
"home.metaDescription": "ओपन-सोर्स, सेल्फ-होस्टेड फ़ाइल-प्रोसेसिंग इंफ्रास्ट्रक्चर। इमेज, वीडियो, ऑडियो, PDF और डॉक्युमेंट में कन्वर्ट करें, कंप्रेस करें, OCR चलाएँ, ट्रांसक्राइब करें और लोकल AI चलाएँ, एक UI, REST API और पाइपलाइन के ज़रिए। CloudConvert, Smallpdf और TinyPNG का निजी, सेल्फ-होस्टेड विकल्प।",
"home.metaTitle": "सेल्फ-होस्टेड फ़ाइल-प्रोसेसिंग इंफ्रास्ट्रक्चर | SnapOtter",
"home.openSource.discord": "Discord से जुड़ें",
"home.openSource.sponsor": "स्पॉन्सर करें",
"home.openSource.star": "GitHub पर स्टार करें",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Cari alat",
"home.hero.subtitle": "Konversi, kompres, OCR, transkripsi, hapus metadata, dan jalankan AI lokal untuk gambar, video, audio, PDF, dan dokumen. Lewat UI, REST API, dan pipeline, di infrastruktur milik Anda sendiri.",
"home.hero.title": "Pemrosesan file privat untuk data yang tidak boleh keluar dari jaringan Anda.",
"home.metaDescription": "Infrastruktur pemrosesan file open source dan self-hosted. Konversi, kompres, OCR, transkripsi, dan jalankan AI lokal untuk gambar, video, audio, PDF, dan dokumen, lewat UI, REST API, dan pipeline. Alternatif privat dan self-hosted untuk CloudConvert, Smallpdf, dan TinyPNG.",
"home.metaTitle": "Infrastruktur Pemrosesan File Self-Hosted | SnapOtter",
"home.openSource.discord": "Gabung Discord",
"home.openSource.sponsor": "Jadi Sponsor",
"home.openSource.star": "Beri Bintang di GitHub",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Cerca strumenti",
"home.hero.subtitle": "Converti, comprimi, applica OCR, trascrivi, rimuovi i metadati ed esegui AI in locale su immagini, video, audio, PDF e documenti. Tramite UI, API REST e pipeline, sull'infrastruttura che possiedi.",
"home.hero.title": "Elaborazione file privata per i dati che non possono uscire dalla tua rete.",
"home.metaDescription": "Infrastruttura open source e self-hosted per l'elaborazione dei file. Converti, comprimi, applica OCR, trascrivi ed esegui AI in locale su immagini, video, audio, PDF e documenti, tramite UI, API REST e pipeline. L'alternativa privata e self-hosted a CloudConvert, Smallpdf e TinyPNG.",
"home.metaTitle": "Elaborazione dei file self-hosted | SnapOtter",
"home.openSource.discord": "Unisciti a Discord",
"home.openSource.sponsor": "Sostieni",
"home.openSource.star": "Metti una stella su GitHub",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "ツールを検索",
"home.hero.subtitle": "画像、動画、音声、PDF、ドキュメントを対象に、変換、圧縮、OCR、文字起こし、メタデータ削除、ローカル AI 処理を実行。UI、REST API、パイプラインを通じて、自分が所有するインフラ上で。",
"home.hero.title": "ネットワークの外に出せないデータのための、プライベートなファイル処理。",
"home.metaDescription": "オープンソースのセルフホスト型ファイル処理基盤。画像、動画、音声、PDF、ドキュメントを対象に、変換、圧縮、OCR、文字起こし、ローカル AI 処理を実行できます。UI、REST API、パイプラインから利用可能。CloudConvert、Smallpdf、TinyPNG のプライベートなセルフホスト代替です。",
"home.metaTitle": "セルフホスト型ファイル処理基盤 | SnapOtter",
"home.openSource.discord": "Discordに参加",
"home.openSource.sponsor": "スポンサーになる",
"home.openSource.star": "GitHubでスター",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "도구 검색",
"home.hero.subtitle": "이미지, 동영상, 오디오, PDF, 문서 전반에서 변환, 압축, OCR, 전사, 메타데이터 제거를 하고 로컬 AI를 실행하세요. 직접 소유한 인프라에서 UI, REST API, 파이프라인으로.",
"home.hero.title": "네트워크를 벗어날 수 없는 데이터를 위한 비공개 파일 처리.",
"home.metaDescription": "오픈 소스 셀프 호스팅 파일 처리 인프라. 이미지, 동영상, 오디오, PDF, 문서 전반에서 변환, 압축, OCR, 전사를 하고 로컬 AI를 실행하세요. UI, REST API, 파이프라인으로 사용할 수 있습니다. CloudConvert, Smallpdf, TinyPNG를 대체하는 비공개 셀프 호스팅 솔루션입니다.",
"home.metaTitle": "셀프 호스팅 파일 처리 인프라 | SnapOtter",
"home.openSource.discord": "Discord 참여하기",
"home.openSource.sponsor": "후원하기",
"home.openSource.star": "GitHub에서 스타 누르기",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Tools zoeken",
"home.hero.subtitle": "Converteer, comprimeer, OCR, transcribeer, verwijder metadata en draai lokale AI op afbeeldingen, video, audio, PDF's en documenten. Via een UI, REST API en pipelines, op infrastructuur die je zelf beheert.",
"home.hero.title": "Privé bestandsverwerking voor data die je netwerk niet mag verlaten.",
"home.metaDescription": "Open source, zelfgehoste infrastructuur voor bestandsverwerking. Converteer, comprimeer, OCR, transcribeer en draai lokale AI op afbeeldingen, video, audio, PDF's en documenten, via een UI, REST API en pipelines. Het private, zelfgehoste alternatief voor CloudConvert, Smallpdf en TinyPNG.",
"home.metaTitle": "Zelfgehoste infrastructuur voor bestandsverwerking | SnapOtter",
"home.openSource.discord": "Word lid op Discord",
"home.openSource.sponsor": "Sponsoren",
"home.openSource.star": "Ster op GitHub",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Szukaj narzędzi",
"home.hero.subtitle": "Konwertuj, kompresuj, wykonuj OCR, transkrybuj, usuwaj metadane i uruchamiaj lokalną AI na obrazach, wideo, audio, plikach PDF i dokumentach. Przez interfejs, REST API i pipeline'y, na infrastrukturze, którą posiadasz.",
"home.hero.title": "Prywatne przetwarzanie plików dla danych, które nie mogą opuścić Twojej sieci.",
"home.metaDescription": "Otwarta, samodzielnie hostowana infrastruktura do przetwarzania plików. Konwertuj, kompresuj, wykonuj OCR, transkrybuj i uruchamiaj lokalną AI na obrazach, wideo, audio, plikach PDF i dokumentach, przez interfejs, REST API i pipeline'y. Prywatna, samodzielnie hostowana alternatywa dla CloudConvert, Smallpdf i TinyPNG.",
"home.metaTitle": "Samodzielnie hostowane przetwarzanie plików | SnapOtter",
"home.openSource.discord": "Dołącz do Discorda",
"home.openSource.sponsor": "Wesprzyj",
"home.openSource.star": "Dodaj gwiazdkę na GitHubie",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Buscar ferramentas",
"home.hero.subtitle": "Converta, comprima, faça OCR, transcreva, remova metadados e execute IA local em imagem, vídeo, áudio, PDF e documentos. Por uma interface, REST API e pipelines, na infraestrutura que é sua.",
"home.hero.title": "Processamento de arquivos privado para dados que não podem sair da sua rede.",
"home.metaDescription": "Infraestrutura de processamento de arquivos open source e self-hosted. Converta, comprima, faça OCR, transcreva e execute IA local em imagem, vídeo, áudio, PDF e documentos, por uma interface, REST API e pipelines. A alternativa privada e self-hosted ao CloudConvert, Smallpdf e TinyPNG.",
"home.metaTitle": "Processamento de arquivos self-hosted | SnapOtter",
"home.openSource.discord": "Entrar no Discord",
"home.openSource.sponsor": "Patrocinar",
"home.openSource.star": "Dar estrela no GitHub",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Поиск инструментов",
"home.hero.subtitle": "Конвертируйте, сжимайте, распознавайте текст (OCR), транскрибируйте, удаляйте метаданные и запускайте локальный ИИ для изображений, видео, аудио, PDF и документов. Через UI, REST API и конвейеры, на вашей собственной инфраструктуре.",
"home.hero.title": "Приватная обработка файлов для данных, которые не должны покидать вашу сеть.",
"home.metaDescription": "Инфраструктура для обработки файлов с открытым исходным кодом и собственным хостингом. Конвертируйте, сжимайте, распознавайте текст (OCR), транскрибируйте и запускайте локальный ИИ для изображений, видео, аудио, PDF и документов через UI, REST API и конвейеры. Приватная self-hosted альтернатива CloudConvert, Smallpdf и TinyPNG.",
"home.metaTitle": "Self-hosted инфраструктура для обработки файлов | SnapOtter",
"home.openSource.discord": "Присоединиться к Discord",
"home.openSource.sponsor": "Поддержать",
"home.openSource.star": "Поставить звезду на GitHub",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Sök verktyg",
"home.hero.subtitle": "Konvertera, komprimera, kör OCR, transkribera, ta bort metadata och kör lokal AI för bild, video, ljud, PDF och dokument. Via ett gränssnitt, REST API och pipelines, på infrastruktur du själv äger.",
"home.hero.title": "Privat filbearbetning för data som inte får lämna ditt nätverk.",
"home.metaDescription": "Självhostad infrastruktur för filbearbetning med öppen källkod. Konvertera, komprimera, kör OCR, transkribera och kör lokal AI för bild, video, ljud, PDF och dokument, via ett gränssnitt, REST API och pipelines. Det privata, självhostade alternativet till CloudConvert, Smallpdf och TinyPNG.",
"home.metaTitle": "Självhostad infrastruktur för filbearbetning | SnapOtter",
"home.openSource.discord": "Gå med i Discord",
"home.openSource.sponsor": "Sponsra",
"home.openSource.star": "Ge en stjärna på GitHub",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "ค้นหาเครื่องมือ",
"home.hero.subtitle": "แปลง บีบอัด OCR ถอดเสียง ลบเมทาดาทา และรัน AI ในเครื่อง ครอบคลุมทั้งภาพ วิดีโอ เสียง PDF และเอกสาร ผ่าน UI, REST API และไปป์ไลน์ บนโครงสร้างพื้นฐานที่คุณเป็นเจ้าของ",
"home.hero.title": "ประมวลผลไฟล์แบบส่วนตัว สำหรับข้อมูลที่ออกจากเครือข่ายของคุณไม่ได้",
"home.metaDescription": "โครงสร้างพื้นฐานสำหรับประมวลผลไฟล์แบบโอเพนซอร์สและโฮสต์เอง แปลง บีบอัด OCR ถอดเสียง และรัน AI ในเครื่อง ครอบคลุมทั้งภาพ วิดีโอ เสียง PDF และเอกสาร ผ่าน UI, REST API และไปป์ไลน์ ทางเลือกส่วนตัวแบบโฮสต์เองแทน CloudConvert, Smallpdf และ TinyPNG",
"home.metaTitle": "โครงสร้างพื้นฐานประมวลผลไฟล์แบบ self-hosted | SnapOtter",
"home.openSource.discord": "เข้าร่วม Discord",
"home.openSource.sponsor": "สนับสนุน",
"home.openSource.star": "กด Star บน GitHub",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Araç ara",
"home.hero.subtitle": "Görüntü, video, ses, PDF ve belgelerde dönüştürün, sıkıştırın, OCR ve transkripsiyon yapın, meta verileri temizleyin ve yerel yapay zekâ çalıştırın. Bir arayüz, REST API ve işlem hatlarıyla, sahip olduğun altyapıda.",
"home.hero.title": "Ağından çıkamayacak veriler için gizli dosya işleme.",
"home.metaDescription": "Açık kaynaklı, kendi sunucunuzda barındırılan dosya işleme altyapısı. Görüntü, video, ses, PDF ve belgelerde dönüştürün, sıkıştırın, OCR ve transkripsiyon yapın, yerel yapay zekâ çalıştırın; bir arayüz, REST API ve işlem hatlarıyla. CloudConvert, Smallpdf ve TinyPNG'ye gizli, kendi sunucunuzda barındırılan alternatif.",
"home.metaTitle": "Kendi Sunucunuzda Dosya İşleme Altyapısı | SnapOtter",
"home.openSource.discord": "Discord'a Katıl",
"home.openSource.sponsor": "Sponsor Ol",
"home.openSource.star": "GitHub'da Yıldızla",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Шукати інструменти",
"home.hero.subtitle": "Конвертуйте, стискайте, розпізнавайте текст (OCR), транскрибуйте, видаляйте метадані та запускайте локальний ШІ для зображень, відео, аудіо, PDF і документів. Через UI, REST API та конвеєри, на інфраструктурі, якою володієте ви.",
"home.hero.title": "Приватна обробка файлів для даних, які не можуть залишати вашу мережу.",
"home.metaDescription": "Інфраструктура для обробки файлів з відкритим кодом і самостійним розгортанням. Конвертуйте, стискайте, розпізнавайте текст (OCR), транскрибуйте та запускайте локальний ШІ для зображень, відео, аудіо, PDF і документів через UI, REST API та конвеєри. Приватна самохостована альтернатива CloudConvert, Smallpdf і TinyPNG.",
"home.metaTitle": "Самохостована інфраструктура для обробки файлів | SnapOtter",
"home.openSource.discord": "Приєднатися до Discord",
"home.openSource.sponsor": "Стати спонсором",
"home.openSource.star": "Поставити зірку на GitHub",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "Tìm công cụ",
"home.hero.subtitle": "Chuyển đổi, nén, OCR, phiên âm, xóa metadata và chạy AI cục bộ trên ảnh, video, âm thanh, PDF và tài liệu. Qua giao diện, REST API và pipeline, trên hạ tầng bạn sở hữu.",
"home.hero.title": "Xử lý tệp riêng tư cho dữ liệu không thể rời khỏi mạng của bạn.",
"home.metaDescription": "Hạ tầng xử lý tệp mã nguồn mở, tự lưu trữ. Chuyển đổi, nén, OCR, phiên âm và chạy AI cục bộ trên ảnh, video, âm thanh, PDF và tài liệu, qua giao diện, REST API và pipeline. Giải pháp thay thế riêng tư, tự lưu trữ cho CloudConvert, Smallpdf và TinyPNG.",
"home.metaTitle": "Hạ tầng xử lý tệp tự lưu trữ | SnapOtter",
"home.openSource.discord": "Tham gia Discord",
"home.openSource.sponsor": "Tài trợ",
"home.openSource.star": "Gắn sao trên GitHub",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "搜索工具",
"home.hero.subtitle": "对图片、视频、音频、PDF 和文档进行转换、压缩、OCR、转录、清除元数据,并运行本地 AI。通过 UI、REST API 和流水线,全部在你自己的基础设施上完成。",
"home.hero.title": "为不能离开你网络的数据提供私密文件处理。",
"home.metaDescription": "开源自托管的文件处理基础设施。对图片、视频、音频、PDF 和文档进行转换、压缩、OCR、转录,并运行本地 AI,可通过 UI、REST API 和流水线使用。CloudConvert、Smallpdf 和 TinyPNG 的私有自托管替代方案。",
"home.metaTitle": "自托管文件处理基础设施 | SnapOtter",
"home.openSource.discord": "加入 Discord",
"home.openSource.sponsor": "赞助",
"home.openSource.star": "在 GitHub 上 Star",
+2
View File
@@ -320,6 +320,8 @@
"home.hero.searchPlaceholder": "搜尋工具",
"home.hero.subtitle": "在圖片、影片、音訊、PDF 與文件之間轉檔、壓縮、OCR、轉錄、清除中繼資料並運行本地 AI。透過 UI、REST API 與流程管線,全在你自己擁有的基礎設施上完成。",
"home.hero.title": "私有檔案處理,讓不能離開內網的資料留在原處。",
"home.metaDescription": "開源、自架式的檔案處理基礎設施。在圖片、影片、音訊、PDF 與文件之間轉檔、壓縮、OCR、轉錄並運行本地 AI,可透過 UI、REST API 與流程管線使用。CloudConvert、Smallpdf 與 TinyPNG 的私有自架式替代方案。",
"home.metaTitle": "自架式檔案處理基礎設施 | SnapOtter",
"home.openSource.discord": "加入 Discord",
"home.openSource.sponsor": "贊助",
"home.openSource.star": "在 GitHub 上給星星",
+1
View File
@@ -1,5 +1,6 @@
---
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes frontmatter imports.
import "@/styles/globals.css";
import { TOOLS } from "@snapotter/shared";
import Analytics from "@/components/Analytics.astro";
+37
View File
@@ -0,0 +1,37 @@
// apps/landing/src/lib/en-only-paths.ts
//
// Which landing paths the build emits in English only.
//
// Two consumers need this answer and used to hold their own copy: astro.config.mjs
// (to strip sitemap alternates) and the page head (to strip hreflang alternates).
// Only the sitemap copy existed, so every English tool-detail page shipped twenty
// `<link rel="alternate">` tags pointing at pages that were never built. One
// definition, imported by both, is the reason that cannot happen again.
//
// Deliberately free of `astro:*` imports so `astro.config.mjs` can import it: the
// config is evaluated before the Astro virtual modules exist.
/**
* Tool DETAIL pages (/tools/<section>/<tool>/) and the /self-hosted pages have no
* per-locale route. The /tools/ index and /tools/<section>/ section pages have one
* path segment fewer and DO stay localized, which is why the tool pattern pins the
* segment count instead of matching a prefix.
*/
const EN_ONLY_PATTERNS = [
/^\/tools\/(?:image|video|audio|pdf|files)\/[^/]+\/?$/,
/^\/self-hosted(?:\/[^/]+)?\/?$/,
];
/** True when `pathname` is served in English only, so it has no localized twin. */
export function isEnglishOnlyPath(pathname: string): boolean {
return EN_ONLY_PATTERNS.some((pattern) => pattern.test(pathname));
}
/** `isEnglishOnlyPath` for a full URL. A value that will not parse is not English-only. */
export function isEnglishOnlyUrl(url: string): boolean {
try {
return isEnglishOnlyPath(new URL(url).pathname);
} catch {
return false;
}
}
+14 -1
View File
@@ -1,6 +1,7 @@
// apps/landing/src/lib/i18n-page.ts
import { getRelativeLocaleUrl } from "astro:i18n";
import { isRtl, LANDING_LOCALES } from "@/i18n";
import { isEnglishOnlyPath } from "@/lib/en-only-paths";
/** Absolute site origin used for canonical + hreflang hrefs. */
export const SITE = "https://snapotter.com";
@@ -44,13 +45,25 @@ export function dirFor(locale: string): "ltr" | "rtl" {
/**
* Reciprocal hreflang alternates for one page path (same path across all locales),
* plus an x-default pointing at English.
*
* English-only pages get the self-reference and x-default and nothing else. Fanning
* them out across every locale would advertise twenty URLs the build never wrote,
* which is a soft 404 per tag to a crawler and a dead link to anything following the
* head. The sitemap has always dropped these alternates; the head now agrees.
*/
export function altLinks(path: string): Array<{ hreflang: string; href: string }> {
const english = `${SITE}${localizeHref("en", path)}`;
if (isEnglishOnlyPath(path)) {
return [
{ hreflang: "en", href: english },
{ hreflang: "x-default", href: english },
];
}
const links = LANDING_LOCALES.map((code) => ({
hreflang: code,
href: `${SITE}${localizeHref(code, path)}`,
}));
links.push({ hreflang: "x-default", href: `${SITE}${localizeHref("en", path)}` });
links.push({ hreflang: "x-default", href: english });
return links;
}
+1
View File
@@ -1,5 +1,6 @@
---
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import Footer from "@/components/Footer.astro";
import Navbar from "@/components/Navbar.astro";
import { t } from "@/i18n";
@@ -11,7 +11,7 @@ import OpenSource from "@/components/OpenSource.astro";
import Pricing from "@/components/Pricing.astro";
import Testimonials from "@/components/Testimonials.astro";
import ToolGrid from "@/components/ToolGrid.astro";
import { LANDING_LOCALES } from "@/i18n";
import { LANDING_LOCALES, t } from "@/i18n";
import Base from "@/layouts/Base.astro";
import { localizeHref } from "@/lib/i18n-page";
@@ -25,9 +25,10 @@ export function getStaticPaths() {
const { locale } = Astro.props;
const PATH = "/";
const pageTitle = "SnapOtter | Self-Hosted File-Processing Infrastructure";
const pageDescription =
"Open-source, self-hosted file-processing infrastructure. Convert, compress, OCR, transcribe, and run local AI across image, video, audio, PDF, and documents, through a UI, REST API, and pipelines. The private, self-hosted alternative to CloudConvert, Smallpdf, and TinyPNG.";
// Localized: these were English literals, so all 20 translated home pages
// shipped an English <title> and meta description.
const pageTitle = t(locale, "home.metaTitle");
const pageDescription = t(locale, "home.metaDescription");
const websiteSchema = {
"@context": "https://schema.org",
@@ -7,7 +7,7 @@ import Footer from "@/components/Footer.astro";
import Navbar from "@/components/Navbar.astro";
import { LANDING_LOCALES, t } from "@/i18n";
import Base from "@/layouts/Base.astro";
import { localizeHref } from "@/lib/i18n-page";
import { enOnlyHref, localizeHref } from "@/lib/i18n-page";
import { loadToolStrings } from "@/lib/tool-strings";
export function getStaticPaths() {
@@ -84,7 +84,7 @@ const toolDesc = (id: string, fallback: string) => toolStrings[id]?.description
<li>
<a
class="tool-item group flex items-start gap-4 rounded-xl border border-border bg-surface p-5 transition-all hover:border-primary hover:shadow-md"
href={localizeHref(locale, `/tools/${section.id}/${tool.id}/`)}
href={enOnlyHref(`/tools/${section.id}/${tool.id}/`)}
>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
@@ -8,7 +8,7 @@ import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import { LANDING_LOCALES, t } from "@/i18n";
import Base from "@/layouts/Base.astro";
import { localizeHref } from "@/lib/i18n-page";
import { enOnlyHref, localizeHref } from "@/lib/i18n-page";
import { loadToolStrings } from "@/lib/tool-strings";
export function getStaticPaths() {
@@ -236,7 +236,7 @@ const collectionJsonLd = {
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{group.tools.map((tool) => (
<a
href={localizeHref(locale, `/tools/${toolSection(tool)}/${tool.id}/`)}
href={enOnlyHref(`/tools/${toolSection(tool)}/${tool.id}/`)}
class="tool-item group flex items-start gap-4 rounded-xl border border-border bg-surface p-5 transition-all hover:border-primary hover:shadow-md"
data-name={toolName(tool.id, tool.name).toLowerCase()}
data-desc={toolDesc(tool.id, tool.description).toLowerCase()}
@@ -16,7 +16,10 @@ export function getStaticPaths() {
}
const { uc } = Astro.props;
const canonical = `https://snapotter.com/self-hosted/${uc.slug}`;
// English-only page, so `path` also tells Base to skip the per-locale hreflang
// fan-out. Trailing slash matches the emitted directory URL.
const PATH = `/self-hosted/${uc.slug}/`;
const canonical = `https://snapotter.com${PATH}`;
const alt = uc.alternativeSlug
? ALTERNATIVES.find((a) => a.slug === uc.alternativeSlug)
: undefined;
@@ -57,7 +60,7 @@ const faqJsonLd =
const allJsonLd = [breadcrumbJsonLd, ...(faqJsonLd ? [faqJsonLd] : [])];
---
<Base title={`${uc.pageTitle} | SnapOtter`} description={uc.metaDescription} canonical={canonical}>
<Base title={`${uc.pageTitle} | SnapOtter`} description={uc.metaDescription} canonical={canonical} path={PATH}>
<Fragment slot="head">
<JsonLd data={allJsonLd} />
</Fragment>
@@ -7,13 +7,16 @@ import Navbar from "@/components/Navbar.astro";
import { USE_CASES } from "@/data/use-cases";
import Base from "@/layouts/Base.astro";
const canonical = "https://snapotter.com/self-hosted";
// English-only page, so `path` also tells Base to skip the per-locale hreflang
// fan-out. Trailing slash matches the emitted /self-hosted/index.html URL.
const PATH = "/self-hosted/";
const canonical = `https://snapotter.com${PATH}`;
const title = "Self-Hosted File Processing | SnapOtter";
const description =
"Self-hosted, private alternatives to the online file tools your team pastes sensitive files into. Image compression, PDF OCR, video conversion, background removal, transcription, and a REST API, on infrastructure you control.";
---
<Base title={title} description={description} canonical={canonical}>
<Base title={title} description={description} canonical={canonical} path={PATH}>
<Navbar />
<main id="main">
<section class="mx-auto max-w-4xl px-6 pt-28 pb-12 text-center md:pt-36">
@@ -161,9 +161,14 @@ const allJsonLd = [breadcrumbJsonLd, softwareJsonLd];
set:html={renderIcon(tool.icon)}
/>
</div>
{/* The category colour tints the pill and the label takes --color-foreground,
which is the same rule the Otter palette already applies to orange: vivid
colours fill, ink labels. As 12px text on its own 8% tint, #3B82F6 came out
at 3.21:1. Ink on that tint is well over 12:1 and the pill still reads as
its category. */}
<span
class="rounded-full px-3 py-1 text-xs font-semibold"
style={`background-color: ${catColor}14; color: ${catColor}`}
class="rounded-full px-3 py-1 text-xs font-semibold text-foreground"
style={`background-color: ${catColor}14`}
>
{category?.name}
</span>
+5
View File
@@ -63,6 +63,11 @@ html {
/* Semantic */
--color-success: #1E7B5C;
--color-danger: #BE3A35;
/* Same split the orange already uses: the vivid token fills and draws (dots,
icons, which only owe 3:1 under 1.4.11), the ink token carries small text.
#BE3A35 as 14px text on the #F0E5DD callout wash measures 4.4:1, just under
AA; the ink is 5.0:1 there and higher on cream and white. */
--color-danger-ink: #B03430;
/* Fonts */
--font-display: "Bricolage Grotesque", system-ui, sans-serif;