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
+3 -3
View File
@@ -1,6 +1,6 @@
// Generates branding/social-preview.png (1280x640) -- the SnapOtter social/OG card.
// Echoes the landing hero: trust badges, the "Every file tool you need / Nothing
// leaves your network" headline, the supporting subhead, and the five modality
// Echoes the landing hero: trust badges, the "Every file tool you need / Your files
// never leave your network" headline, the supporting subhead, and the five modality
// cards. Rendered from HTML with the real brand fonts via headless Chromium.
// Run: node scripts/branding/generate-social-preview.mjs
//
@@ -127,7 +127,7 @@ html,body{width:1280px;height:640px}
</style></head><body><div class="canvas">
<div class="brand"><img src="logo-512.png" alt=""/><span class="wm">Snap<span class="o">Otter</span></span><span class="pill">${total} tools</span></div>
<div class="badges">${badgesHtml}</div>
<div class="headline">Every <span class="f">file</span> tool you need.<span class="hl2">Nothing leaves your network.</span></div>
<div class="headline">Every <span class="f">file</span> tool you need.<span class="hl2">Your files never leave your network.</span></div>
<div class="subhead">The file-processing suite for teams that keep sensitive data in-house.</div>
<div class="cards">${cardsHtml}</div>
</div></body></html>`;
+185
View File
@@ -0,0 +1,185 @@
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { CONVERSION_PRESETS } from "../packages/shared/src/conversion-presets.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const I18N_DIR = join(__dirname, "../packages/shared/src/i18n");
const BEGIN = " // BEGIN conversion-presets (generated by scripts/generate-conversion-i18n.mjs)";
const END = " // END conversion-presets";
// Per-locale phrasing: name template and description template. {from}/{to} are format labels (kept as-is).
const L = {
en: {
name: (f, t) => `${f} to ${t}`,
desc: (f, t) => `Convert ${f} to ${t} online. Fast, free, private, no signup.`,
},
de: {
name: (f, t) => `${f} in ${t} umwandeln`,
desc: (f, t) => `${f} online in ${t} umwandeln. Schnell, kostenlos und privat.`,
},
es: {
name: (f, t) => `${f} a ${t}`,
desc: (f, t) => `Convierte ${f} a ${t} en línea. Rápido, gratis y privado.`,
},
fr: {
name: (f, t) => `${f} en ${t}`,
desc: (f, t) => `Convertir ${f} en ${t} en ligne. Rapide, gratuit et privé.`,
},
it: {
name: (f, t) => `Da ${f} a ${t}`,
desc: (f, t) => `Converti ${f} in ${t} online. Veloce, gratuito e privato.`,
},
"pt-BR": {
name: (f, t) => `${f} para ${t}`,
desc: (f, t) => `Converta ${f} para ${t} online. Rápido, grátis e privado.`,
},
nl: {
name: (f, t) => `${f} naar ${t}`,
desc: (f, t) => `Zet ${f} online om naar ${t}. Snel, gratis en privé.`,
},
sv: {
name: (f, t) => `${f} till ${t}`,
desc: (f, t) => `Konvertera ${f} till ${t} online. Snabbt, gratis och privat.`,
},
pl: {
name: (f, t) => `${f} na ${t}`,
desc: (f, t) => `Konwertuj ${f} na ${t} online. Szybko, za darmo i prywatnie.`,
},
ru: {
name: (f, t) => `${f} в ${t}`,
desc: (f, t) => `Конвертируйте ${f} в ${t} онлайн. Быстро, бесплатно и приватно.`,
},
uk: {
name: (f, t) => `${f} у ${t}`,
desc: (f, t) => `Конвертуйте ${f} у ${t} онлайн. Швидко, безкоштовно та приватно.`,
},
tr: {
name: (f, t) => `${f} - ${t}`,
desc: (f, t) => `${f} dosyasını çevrimiçi ${t} biçimine dönüştürün. Hızlı, ücretsiz ve gizli.`,
},
ar: {
name: (f, t) => `${f} إلى ${t}`,
desc: (f, t) => `حوّل ${f} إلى ${t} عبر الإنترنت. سريع ومجاني وخاص.`,
},
hi: {
name: (f, t) => `${f} से ${t}`,
desc: (f, t) => `${f} को ${t} में ऑनलाइन बदलें। तेज़, मुफ़्त और निजी।`,
},
vi: {
name: (f, t) => `${f} sang ${t}`,
desc: (f, t) => `Chuyển đổi ${f} sang ${t} trực tuyến. Nhanh, miễn phí và riêng tư.`,
},
id: {
name: (f, t) => `${f} ke ${t}`,
desc: (f, t) => `Konversi ${f} ke ${t} online. Cepat, gratis, dan privat.`,
},
th: {
name: (f, t) => `${f} เป็น ${t}`,
desc: (f, t) => `แปลง ${f} เป็น ${t} ออนไลน์ รวดเร็ว ฟรี และเป็นส่วนตัว`,
},
ja: {
name: (f, t) => `${f}${t}に変換`,
desc: (f, t) => `${f}${t}にオンラインで変換。高速、無料、プライベート。`,
},
ko: {
name: (f, t) => `${f}을(를) ${t}(으)로`,
desc: (f, t) => `${f}을(를) ${t}(으)로 온라인 변환. 빠르고 무료이며 비공개입니다.`,
},
"zh-CN": {
name: (f, t) => `${f}${t}`,
desc: (f, t) => `在线将 ${f} 转换为 ${t}。快速、免费、私密。`,
},
"zh-TW": {
name: (f, t) => `${f}${t}`,
desc: (f, t) => `線上將 ${f} 轉換為 ${t}。快速、免費、私密。`,
},
};
// Localized "Extract Text from Image (OCR)" per locale (keep the "(OCR)" token literal).
const OCR_NAMES = {
en: "Extract Text from Image (OCR)",
de: "Text aus Bild extrahieren (OCR)",
es: "Extraer texto de imagen (OCR)",
fr: "Extraire le texte d'une image (OCR)",
it: "Estrai testo da immagine (OCR)",
"pt-BR": "Extrair texto de imagem (OCR)",
nl: "Tekst uit afbeelding halen (OCR)",
sv: "Extrahera text från bild (OCR)",
pl: "Wyodrębnij tekst z obrazu (OCR)",
ru: "Извлечь текст из изображения (OCR)",
uk: "Витягти текст із зображення (OCR)",
tr: "Görüntüden Metin Çıkarma (OCR)",
ar: "استخراج النص من صورة (OCR)",
hi: "छवि से टेक्स्ट निकालें (OCR)",
vi: "Trích xuất văn bản từ ảnh (OCR)",
id: "Ekstrak Teks dari Gambar (OCR)",
th: "แยกข้อความจากรูปภาพ (OCR)",
ja: "画像から文字を抽出 (OCR)",
ko: "이미지에서 텍스트 추출 (OCR)",
"zh-CN": "从图片提取文字 (OCR)",
"zh-TW": "從圖片擷取文字 (OCR)",
};
function block(locale) {
const tpl = L[locale] ?? L.en;
const lines = CONVERSION_PRESETS.map((p) => {
const name = tpl.name(p.from, p.to).replace(/[\\"]/g, "\\$&");
const desc = tpl.desc(p.from, p.to).replace(/[\\"]/g, "\\$&");
return ` "${p.id}": { name: "${name}", description: "${desc}" },`;
});
return [BEGIN, ...lines, END].join("\n");
}
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Rename the display tools.ocr.name (the first `ocr:` block, the one whose first key is `name`). */
function renameOcr(src, locale) {
const name = (OCR_NAMES[locale] ?? OCR_NAMES.en).replace(/[\\"]/g, "\\$&");
return src.replace(/(ocr:\s*\{\s*name:\s*")(?:[^"\\]|\\.)*(")/, `$1${name}$2`);
}
function injectInto(file, locale) {
let src = readFileSync(file, "utf8");
const gen = block(locale);
if (src.includes(BEGIN)) {
src = src.replace(new RegExp(`${escapeRe(BEGIN)}[\\s\\S]*?${escapeRe(END)}`), gen);
} else {
// Insert right after the `tools: {` line.
src = src.replace(/(\n\s*tools:\s*\{\n)/, `$1${gen}\n`);
}
src = renameOcr(src, locale);
writeFileSync(file, src);
}
const LOCALES = [
"en",
"zh-CN",
"zh-TW",
"ja",
"ko",
"es",
"fr",
"it",
"pt-BR",
"de",
"nl",
"sv",
"ru",
"pl",
"uk",
"ar",
"tr",
"hi",
"vi",
"id",
"th",
];
for (const code of LOCALES) {
injectInto(join(I18N_DIR, `${code}.ts`), code);
}
console.log(
`Injected ${CONVERSION_PRESETS.length} preset entries into ${LOCALES.length} locales; renamed tools.ocr.name`,
);
+79
View File
@@ -0,0 +1,79 @@
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { CONVERSION_PRESETS } from "../packages/shared/src/conversion-presets.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SEO = join(__dirname, "../apps/landing/src/data/tool-seo.ts");
const BEGIN = " // BEGIN conversion-presets (generated by scripts/generate-conversion-seo.mjs)";
const END = " // END conversion-presets";
// Per-preset honest overrides for conversions whose result is an approximation
// rather than a faithful copy, so the SEO copy sets correct expectations.
const OVERRIDES = {
"eps-to-svg": (j) => ` ${j("eps-to-svg")}: {
searchTitle: ${j("EPS to SVG Converter Online Free")},
longDescription: ${j("Convert EPS files to SVG by tracing the artwork into vector paths. This works best for logos, icons, and line art. Complex EPS files with gradients, photographic content, or fine detail can lose fidelity, because the result is a traced approximation rather than a one-to-one copy of the original vectors. Runs on your own SnapOtter instance, never uploaded to a third party.")},
useCases: [
${j("Trace an EPS logo or icon into a clean, scalable SVG")},
${j("Convert simple EPS line art for the web without Illustrator")},
${j("Batch convert many EPS files to traced SVG at once")},
],
features: [
${j("Traces EPS artwork into SVG vector paths (not a 1:1 vector copy)")},
${j("Best results on logos, icons, and line art")},
${j("Batch processing for multiple files")},
${j("Private: runs on your own instance, no third-party upload")},
],
faqs: [
{ q: ${j("Does this preserve the original EPS vectors exactly?")}, a: ${j("No. It renders the EPS and traces the result into new SVG paths. Simple line art and logos convert cleanly, but complex EPS with gradients, effects, or embedded images can lose detail. For a faithful vector copy, keep the original EPS or use a dedicated vector editor.")} },
{ q: ${j("How do I convert EPS to SVG?")}, a: ${j("Upload your EPS file, then download the traced SVG. SnapOtter processes it on your instance.")} },
{ q: ${j("Are my files private?")}, a: ${j("Yes. Files are processed on your own server and are not sent to any third party.")} },
],
},`,
};
function entry(p) {
const F = p.from;
const T = p.to;
const j = (s) => JSON.stringify(s);
if (OVERRIDES[p.id]) return OVERRIDES[p.id](j);
return ` ${j(p.id)}: {
searchTitle: ${j(`${F} to ${T} Converter Online Free`)},
longDescription: ${j(`Convert ${F} files to ${T} online, free and private. Your files are processed on your own SnapOtter instance, never uploaded to a third party. Batch convert multiple ${F} files to ${T} at once.`)},
useCases: [
${j(`Convert a ${F} file to ${T} without installing software`)},
${j(`Batch convert many ${F} files to ${T} at once`)},
${j(`Prepare ${T} files for apps that do not accept ${F}`)},
],
features: [
${j(`${F} to ${T} conversion in your browser`)},
${j("Batch processing for multiple files")},
${j("Private: runs on your own instance, no third-party upload")},
${j("Free and unlimited on self-hosted SnapOtter")},
],
faqs: [
{ q: ${j(`How do I convert ${F} to ${T}?`)}, a: ${j(`Upload your ${F} file, then download the converted ${T}. SnapOtter processes it on your instance.`)} },
{ q: ${j(`Is ${F} to ${T} conversion free?`)}, a: ${j("Yes. Self-hosted SnapOtter has no limits or watermarks.")} },
{ q: ${j("Are my files private?")}, a: ${j("Yes. Files are processed on your own server and are not sent to any third party.")} },
],
},`;
}
function esc(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function main() {
let src = readFileSync(SEO, "utf8");
const gen = [BEGIN, ...CONVERSION_PRESETS.map(entry), END].join("\n");
if (src.includes(BEGIN)) {
src = src.replace(new RegExp(`${esc(BEGIN)}[\\s\\S]*?${esc(END)}`), gen);
} else {
// Insert right after `export const TOOL_SEO: Record<string, ToolSeo> = {`.
src = src.replace(/(export const TOOL_SEO: Record<string, ToolSeo> = \{\n)/, `$1${gen}\n`);
}
writeFileSync(SEO, src);
console.log(`Injected ${CONVERSION_PRESETS.length} SEO entries`);
}
main();