feat: pipeline templates, analytics opt-out, 83 conversion presets, positioning + e2e modernization

Lands five integrated branches: pipeline templates (#355), analytics opt-out (#354), 83 conversion presets bringing the catalog to 240 tools (#356), self-hosted positioning (#353), and e2e modernization (#351).

Integration fixes: aligned stale web analytics tests with the opt-out/allow-list model, closed 3 CodeQL incomplete-sanitization alerts in the i18n generator, resolved settings/index/docs/format-matrix conflicts, and corrected tool counts to 240.
This commit is contained in:
SnapOtter
2026-06-28 18:57:53 +08:00
committed by GitHub
parent 88af8d46fb
commit 63a03d26f2
421 changed files with 17308 additions and 2540 deletions
@@ -0,0 +1,246 @@
---
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import { ALTERNATIVES } from "@/data/alternatives";
import Base from "@/layouts/Base.astro";
export function getStaticPaths() {
return ALTERNATIVES.map((alt) => ({
params: { slug: alt.slug },
props: { alt },
}));
}
const { alt } = Astro.props;
const canonical = `https://snapotter.com/alternatives/${alt.slug}`;
const others = ALTERNATIVES.filter((a) => a.slug !== alt.slug);
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
{
"@type": "ListItem",
position: 2,
name: "Alternatives",
item: "https://snapotter.com/alternatives",
},
{ "@type": "ListItem", position: 3, name: alt.competitor, item: canonical },
],
};
const faqJsonLd =
alt.faqs.length > 0
? {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: alt.faqs.map((faq) => ({
"@type": "Question",
name: faq.q,
acceptedAnswer: { "@type": "Answer", text: faq.a },
})),
}
: null;
const allJsonLd = [breadcrumbJsonLd, ...(faqJsonLd ? [faqJsonLd] : [])];
---
<Base title={`${alt.pageTitle} | SnapOtter`} description={alt.metaDescription} canonical={canonical}>
<Fragment slot="head">
<JsonLd data={allJsonLd} />
</Fragment>
<Navbar />
<main id="main">
<!-- Breadcrumb -->
<nav class="mx-auto max-w-4xl px-6 pt-28 md:pt-36" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-muted">
<li><a href="/" class="transition-colors hover:text-foreground">Home</a></li>
<li aria-hidden="true">/</li>
<li>
<a href="/alternatives" class="transition-colors hover:text-foreground">Alternatives</a>
</li>
<li aria-hidden="true">/</li>
<li class="font-medium text-foreground">{alt.competitor}</li>
</ol>
</nav>
<!-- Hero -->
<section class="mx-auto max-w-4xl px-6 pt-8 pb-12">
<div class="reveal">
<span class="rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-semibold text-primary-dark">
{alt.category}
</span>
<h1 class="mt-5 font-display text-4xl font-bold tracking-tight md:text-5xl">
{alt.h1}
</h1>
<p class="mt-4 text-lg leading-relaxed text-muted md:text-xl">
{alt.intro}
</p>
<div class="mt-8 flex flex-wrap gap-4">
<a
href="https://docs.snapotter.com/guide/getting-started"
target="_blank"
rel="noopener noreferrer"
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
>
Deploy with Docker
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</a>
<a
href="https://demo.snapotter.com"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
>
Try the live demo
</a>
</div>
</div>
</section>
<!-- Comparison table -->
<section class="border-t border-border bg-background-alt py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
SnapOtter vs {alt.competitor}
</h2>
<div class="mt-8 overflow-x-auto rounded-xl border border-border bg-surface">
<table class="w-full text-sm">
<thead class="border-b border-border bg-background-alt">
<tr>
<th class="px-5 py-4 text-start font-semibold">&nbsp;</th>
<th class="px-5 py-4 text-start font-semibold text-primary-dark">SnapOtter</th>
<th class="px-5 py-4 text-start font-semibold">{alt.competitor}</th>
</tr>
</thead>
<tbody>
{alt.rows.map((row) => (
<tr class="border-b border-border last:border-0">
<td class="px-5 py-4 font-medium">{row.feature}</td>
<td class="px-5 py-4">
<span class="flex items-start gap-2">
{row.snapotterWins && (
<svg class="mt-0.5 h-4 w-4 shrink-0 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
<span>{row.snapotter}</span>
</span>
</td>
<td class="px-5 py-4 text-muted">{row.competitor}</td>
</tr>
))}
</tbody>
</table>
</div>
<p class="mt-4 text-xs text-muted">
Comparison reflects the open-source editions and publicly documented plans. Vendor
features change; check current docs before deciding.
</p>
</div>
</div>
</section>
<!-- Breadth callout -->
<section class="py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal flex items-start gap-4">
<svg class="mt-1 h-6 w-6 shrink-0 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</svg>
<div>
<h2 class="font-display text-xl font-bold">One stack, all five file types</h2>
<p class="mt-2 text-sm leading-relaxed text-muted">{alt.breadth}</p>
</div>
</div>
</div>
</section>
<!-- FAQs -->
{alt.faqs.length > 0 && (
<section class="border-t border-border bg-background-alt py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
Frequently asked questions
</h2>
<dl class="mt-8 space-y-6">
{alt.faqs.map((faq) => (
<div class="rounded-xl border border-border bg-surface p-6">
<dt class="text-sm font-semibold">{faq.q}</dt>
<dd class="mt-3 text-sm leading-relaxed text-muted">{faq.a}</dd>
</div>
))}
</dl>
</div>
</div>
</section>
)}
<!-- Other comparisons -->
<section class="py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<h2 class="font-display text-2xl font-bold tracking-tight">More comparisons</h2>
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{others.map((o) => (
<a
href={`/alternatives/${o.slug}`}
class="group flex flex-col rounded-xl border border-border bg-surface p-5 transition-all hover:border-primary hover:shadow-md"
>
<span class="text-sm font-bold">SnapOtter vs {o.competitor}</span>
<span class="mt-1 text-xs text-muted">{o.category}</span>
</a>
))}
</div>
</div>
</div>
</section>
<!-- Bottom CTA -->
<section class="border-t border-border py-20">
<div class="mx-auto max-w-4xl px-6 text-center">
<div class="reveal">
<h2 class="font-display text-3xl font-bold tracking-tight">
Run it on your own server
</h2>
<p class="mx-auto mt-4 max-w-lg text-muted">
240 tools across image, video, audio, PDF, and files. Open source, free forever, and your
files never leave your network.
</p>
<div class="mt-8 flex flex-wrap justify-center gap-4">
<a
href="https://docs.snapotter.com/guide/getting-started"
target="_blank"
rel="noopener noreferrer"
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
>
Get started
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</a>
<a
href="/alternatives"
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
>
All alternatives
</a>
</div>
</div>
</div>
</section>
</main>
<Footer />
</Base>
@@ -0,0 +1,161 @@
---
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import { ALTERNATIVES } from "@/data/alternatives";
import Base from "@/layouts/Base.astro";
const canonical = "https://snapotter.com/alternatives";
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
{ "@type": "ListItem", position: 2, name: "Alternatives", item: canonical },
],
};
const itemListJsonLd = {
"@context": "https://schema.org",
"@type": "ItemList",
itemListElement: ALTERNATIVES.map((alt, i) => ({
"@type": "ListItem",
position: i + 1,
name: alt.pageTitle,
url: `https://snapotter.com/alternatives/${alt.slug}`,
})),
};
const allJsonLd = [breadcrumbJsonLd, itemListJsonLd];
---
<Base
title="Open-Source, Self-Hosted Alternatives to Cloud File Tools | SnapOtter"
description="Open-source, self-hosted alternatives to Smallpdf, iLovePDF, TinyPNG, CloudConvert, and Otter.ai. One stack for image, video, audio, PDF, and files, on infrastructure you own."
canonical={canonical}
>
<Fragment slot="head">
<JsonLd data={allJsonLd} />
</Fragment>
<Navbar />
<main id="main">
<!-- Hero -->
<section class="mx-auto max-w-4xl px-6 pt-28 pb-12 text-center md:pt-36">
<div class="reveal">
<h1 class="font-display text-4xl font-bold tracking-tight md:text-5xl">
Open-source alternatives to cloud file tools
</h1>
<p class="mx-auto mt-5 max-w-2xl text-lg leading-relaxed text-muted md:text-xl">
Most file tools upload your work to someone else's server. SnapOtter runs the same kinds of
tools on yours: 240 of them across image, video, audio, PDF, and files, in one self-hosted
stack.
</p>
</div>
</section>
<!-- Cards -->
<section class="mx-auto max-w-5xl px-6 pb-16">
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{ALTERNATIVES.map((alt) => (
<a
href={`/alternatives/${alt.slug}`}
class="group flex flex-col rounded-xl border border-border bg-surface p-6 transition-all hover:border-primary hover:shadow-md"
>
<span class="text-xs font-semibold text-primary-dark">{alt.category}</span>
<span class="mt-2 font-display text-lg font-bold tracking-tight">
{alt.competitorOpenSource
? `SnapOtter vs ${alt.competitor}`
: `The alternative to ${alt.competitor}`}
</span>
<span class="mt-2 text-sm leading-relaxed text-muted">{alt.intro}</span>
<span class="mt-auto inline-flex items-center gap-1 pt-5 text-sm font-medium text-primary transition-all group-hover:gap-2">
Compare
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</span>
</a>
))}
</div>
</section>
<!-- Why self-hosted -->
<section class="border-t border-border bg-background-alt py-16">
<div class="mx-auto max-w-4xl px-6">
<div class="reveal">
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">
Why teams pick the self-hosted route
</h2>
<div class="mt-8 grid gap-6 sm:grid-cols-2">
<div>
<h3 class="text-sm font-semibold">Your files stay yours</h3>
<p class="mt-2 text-sm leading-relaxed text-muted">
Processing happens on hardware you control. Nothing is uploaded to a third-party
service, which matters when the file is a contract, a medical scan, or unreleased work.
</p>
</div>
<div>
<h3 class="text-sm font-semibold">No quotas or per-file fees</h3>
<p class="mt-2 text-sm leading-relaxed text-muted">
The only limit is your machine. No monthly cap, no per-conversion billing, no
watermarks on the free output.
</p>
</div>
<div>
<h3 class="text-sm font-semibold">One stack, five file types</h3>
<p class="mt-2 text-sm leading-relaxed text-muted">
Image, video, audio, PDF, and files in a single deployment, instead of a separate
account for each job.
</p>
</div>
<div>
<h3 class="text-sm font-semibold">Open source, auditable</h3>
<p class="mt-2 text-sm leading-relaxed text-muted">
AGPLv3. Your team can read every line, run it air-gapped, and keep it as long as you
like.
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Bottom CTA -->
<section class="py-20">
<div class="mx-auto max-w-4xl px-6 text-center">
<div class="reveal">
<h2 class="font-display text-3xl font-bold tracking-tight">Run it on your own server</h2>
<p class="mx-auto mt-4 max-w-lg text-muted">
Open source, free forever, and your files never leave your network.
</p>
<div class="mt-8 flex flex-wrap justify-center gap-4">
<a
href="https://docs.snapotter.com/guide/getting-started"
target="_blank"
rel="noopener noreferrer"
class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold"
>
Get started
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</a>
<a
href="https://github.com/snapotter-hq/snapotter"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt"
>
View on GitHub
</a>
</div>
</div>
</div>
</section>
</main>
<Footer />
</Base>
+1 -1
View File
@@ -33,7 +33,7 @@ const faqs = [
{
question: "Does SnapOtter collect any analytics?",
answer:
"Analytics is completely optional and disabled by default. If you choose to enable it, SnapOtter collects anonymous usage data and error logs to help improve the software. No personal data or file content is ever collected. You have full control over this in the settings and can disable it at any time.",
"Anonymous product analytics is on by default and never includes your files or their contents. Turn it off any time with a one-click admin toggle in Settings > System > Privacy.",
},
{
question: "Can I use SnapOtter in my company?",
+10 -4
View File
@@ -86,12 +86,18 @@ const breadcrumbJsonLd = {
<h2 class="text-lg font-semibold text-foreground">Analytics</h2>
<ul class="mt-3 list-disc space-y-2 pl-5">
<li>
Basic analytics (tool usage patterns, error reports) help improve the software.
Your files and personal data are never included.
Anonymous product analytics (tool usage patterns, error reports) help improve the
software. Your files, file names, and personal data are never included.
</li>
<li>
You can disable analytics at any time -- SnapOtter works normally without it.
Rebuild with <code class="text-xs">--build-arg SNAPOTTER_ANALYTICS=off</code>.
It is on by default. An administrator can turn it off any time with a one-click
toggle under Settings &gt; System &gt; Privacy. No rebuild is required, and it stops
immediately for the whole instance.
</li>
<li>
To build an image that can never emit analytics, set the
<code class="text-xs">SNAPOTTER_ANALYTICS=off</code> build arg as a compile-time
hard-off.
</li>
</ul>
</section>
+41 -41
View File
@@ -190,6 +190,7 @@ const collectionJsonLd = {
data-name={tool.name.toLowerCase()}
data-desc={tool.description.toLowerCase()}
data-modality={tool.modality}
data-keywords={(tool.keywords ?? []).join(" ")}
>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
@@ -265,64 +266,63 @@ const collectionJsonLd = {
</section>
</main>
<script is:inline>
(function () {
var search = document.getElementById("tools-search");
var countEl = document.getElementById("tools-count");
var emptyEl = document.getElementById("tools-empty");
var items = Array.prototype.slice.call(document.querySelectorAll(".tool-item"));
var sections = Array.prototype.slice.call(document.querySelectorAll("[data-cat-section]"));
var modBtns = Array.prototype.slice.call(document.querySelectorAll(".tools-mod-btn"));
var total = items.length;
var activeMod = "all";
<script>
import { matchTool } from "../../lib/tool-search";
function setBtns() {
modBtns.forEach(function (b) {
var on = b.getAttribute("data-modality") === activeMod;
const search = document.getElementById("tools-search") as HTMLInputElement | null;
const countEl = document.getElementById("tools-count");
const emptyEl = document.getElementById("tools-empty");
const items = Array.from(document.querySelectorAll<HTMLElement>(".tool-item"));
const sections = Array.from(document.querySelectorAll<HTMLElement>("[data-cat-section]"));
const modBtns = Array.from(document.querySelectorAll<HTMLElement>(".tools-mod-btn"));
const total = items.length;
let activeMod = "all";
if (search && countEl && emptyEl) {
const setBtns = () => {
for (const b of modBtns) {
const on = b.getAttribute("data-modality") === activeMod;
b.className =
"tools-mod-btn shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors " +
(on ? "bg-primary text-white" : "border border-border bg-surface hover:bg-background-alt");
});
}
}
};
function filter() {
var q = search.value.toLowerCase().trim();
var visible = 0;
items.forEach(function (el) {
var matchMod = activeMod === "all" || el.getAttribute("data-modality") === activeMod;
var matchQ =
!q ||
el.getAttribute("data-name").indexOf(q) !== -1 ||
el.getAttribute("data-desc").indexOf(q) !== -1;
var show = matchMod && matchQ;
const filter = () => {
const q = search.value.trim();
let visible = 0;
for (const el of items) {
const matchMod = activeMod === "all" || el.getAttribute("data-modality") === activeMod;
const hay =
`${el.getAttribute("data-name") || ""} ${el.getAttribute("data-desc") || ""} ` +
`${el.getAttribute("data-keywords") || ""}`;
const matchQ = !q || matchTool(q, hay);
const show = matchMod && matchQ;
el.style.display = show ? "" : "none";
if (show) visible++;
});
sections.forEach(function (sec) {
var anyVisible = Array.prototype.some.call(
sec.querySelectorAll(".tool-item"),
function (el) {
return el.style.display !== "none";
},
}
for (const sec of sections) {
const anyVisible = Array.from(sec.querySelectorAll<HTMLElement>(".tool-item")).some(
(el) => el.style.display !== "none",
);
sec.style.display = anyVisible ? "" : "none";
});
}
emptyEl.style.display = visible === 0 ? "block" : "none";
countEl.textContent =
q || activeMod !== "all"
? "Showing " + visible + " of " + total + " tools"
: "Showing all " + total + " tools";
}
? `Showing ${visible} of ${total} tools`
: `Showing all ${total} tools`;
};
search.addEventListener("input", filter);
modBtns.forEach(function (b) {
b.addEventListener("click", function () {
activeMod = b.getAttribute("data-modality");
for (const b of modBtns) {
b.addEventListener("click", () => {
activeMod = b.getAttribute("data-modality") ?? "all";
setBtns();
filter();
});
});
})();
}
}
</script>
<Footer />