mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add landing tool command center (#386)
This commit is contained in:
@@ -3,12 +3,15 @@
|
|||||||
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||||
import { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
|
import { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
|
import type { ModalityFilter, ToolSearchItem } from "../lib/tool-search";
|
||||||
import SectionHeading from "./SectionHeading.astro";
|
import SectionHeading from "./SectionHeading.astro";
|
||||||
|
|
||||||
const toolCount = TOOLS.length;
|
const toolCount = TOOLS.length;
|
||||||
const categoryMap = new Map(CATEGORIES.map((c) => [c.id, c]));
|
const categoryMap = new Map(CATEGORIES.map((c) => [c.id, c]));
|
||||||
|
|
||||||
const modalities = [
|
type ToolModality = ToolSearchItem["modality"];
|
||||||
|
|
||||||
|
const modalities: { id: ModalityFilter; label: string; count: number }[] = [
|
||||||
{ id: "all", label: "All", count: TOOLS.length },
|
{ id: "all", label: "All", count: TOOLS.length },
|
||||||
{ id: "image", label: "Image", count: TOOLS.filter((t) => t.modality === "image").length },
|
{ id: "image", label: "Image", count: TOOLS.filter((t) => t.modality === "image").length },
|
||||||
{ id: "video", label: "Video", count: TOOLS.filter((t) => t.modality === "video").length },
|
{ id: "video", label: "Video", count: TOOLS.filter((t) => t.modality === "video").length },
|
||||||
@@ -20,6 +23,94 @@ const modalities = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const workflows = [
|
||||||
|
{
|
||||||
|
id: "optimize",
|
||||||
|
label: "Optimize delivery",
|
||||||
|
description: "Compress, resize, convert, and tune assets for fast delivery.",
|
||||||
|
query: "optimize files for web",
|
||||||
|
icon: "Gauge",
|
||||||
|
metric: "Media + PDF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "convert",
|
||||||
|
label: "Convert formats",
|
||||||
|
description: "Find exact converters by input, output, or plain-language intent.",
|
||||||
|
query: "convert files to another format",
|
||||||
|
icon: "Repeat2",
|
||||||
|
metric: "100+ routes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pdf",
|
||||||
|
label: "Prepare documents",
|
||||||
|
description: "Merge, split, compress, protect, redact, and normalize PDFs.",
|
||||||
|
query: "prepare pdf documents",
|
||||||
|
icon: "FileStack",
|
||||||
|
metric: "PDF workflows",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "metadata",
|
||||||
|
label: "Clean metadata",
|
||||||
|
description: "Strip EXIF, GPS, document, media, and archive metadata.",
|
||||||
|
query: "remove metadata",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
metric: "Privacy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "batch",
|
||||||
|
label: "Batch operations",
|
||||||
|
description: "Rename, archive, extract, and process repeatable file sets.",
|
||||||
|
query: "batch file operations",
|
||||||
|
icon: "ListChecks",
|
||||||
|
metric: "Bulk tools",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai",
|
||||||
|
label: "Enhance with AI",
|
||||||
|
description: "Restore, upscale, remove backgrounds, OCR, and transcribe locally.",
|
||||||
|
query: "ai enhance extract text remove background",
|
||||||
|
icon: "Sparkles",
|
||||||
|
metric: "Local AI",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflowAliasesByToolId: Record<string, string[]> = {
|
||||||
|
"bulk-rename": ["batch file operations", "rename many files"],
|
||||||
|
"compress-pdf": ["prepare pdf documents", "optimize delivery"],
|
||||||
|
"compress-video": ["optimize delivery", "compress media"],
|
||||||
|
"convert-audio": ["convert formats", "audio converter"],
|
||||||
|
"convert-document": ["convert formats", "prepare documents"],
|
||||||
|
"convert-video": ["convert formats", "video converter"],
|
||||||
|
"create-zip": ["batch file operations", "archive files"],
|
||||||
|
"extract-zip": ["batch file operations", "extract archive"],
|
||||||
|
"image-to-pdf": ["prepare documents", "convert images to pdf"],
|
||||||
|
"merge-pdf": ["prepare pdf documents", "combine pdf"],
|
||||||
|
"optimize-for-web": ["optimize delivery", "web delivery"],
|
||||||
|
"pdfa-convert": ["prepare pdf documents", "archive pdf"],
|
||||||
|
"redact-pdf": ["prepare pdf documents", "hide sensitive text"],
|
||||||
|
"remove-background": ["enhance with ai", "transparent background"],
|
||||||
|
"remove-metadata": ["clean metadata", "strip exif"],
|
||||||
|
"split-pdf": ["prepare pdf documents", "extract pages"],
|
||||||
|
"transcribe-audio": ["enhance with ai", "speech to text"],
|
||||||
|
"watermark-pdf": ["prepare pdf documents", "add watermark"],
|
||||||
|
"word-to-pdf": ["prepare documents", "convert document to pdf"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultToolIds = [
|
||||||
|
"resize",
|
||||||
|
"compress",
|
||||||
|
"convert",
|
||||||
|
"remove-background",
|
||||||
|
"optimize-for-web",
|
||||||
|
"ocr",
|
||||||
|
"merge-pdf",
|
||||||
|
"split-pdf",
|
||||||
|
"compress-pdf",
|
||||||
|
"compress-video",
|
||||||
|
"convert-audio",
|
||||||
|
"extract-zip",
|
||||||
|
];
|
||||||
|
|
||||||
function renderIcon(iconName: string): string {
|
function renderIcon(iconName: string): string {
|
||||||
const iconData = (lucideIcons as Record<string, unknown>)[iconName];
|
const iconData = (lucideIcons as Record<string, unknown>)[iconName];
|
||||||
if (!iconData || !Array.isArray(iconData)) return "";
|
if (!iconData || !Array.isArray(iconData)) return "";
|
||||||
@@ -32,164 +123,537 @@ function renderIcon(iconName: string): string {
|
|||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toToolSearchItem(tool: (typeof TOOLS)[number]): ToolSearchItem {
|
||||||
|
const category = categoryMap.get(tool.category);
|
||||||
|
return {
|
||||||
|
id: tool.id,
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
modality: tool.modality as ToolModality,
|
||||||
|
category: tool.category,
|
||||||
|
url: `/tools/${toolSection(tool)}/${tool.id}/`,
|
||||||
|
icon: tool.icon,
|
||||||
|
iconSvg: renderIcon(tool.icon),
|
||||||
|
color: category?.color ?? "#E07832",
|
||||||
|
acceptedInputs: [...tool.acceptedInputs],
|
||||||
|
outputModality: tool.outputModality as ToolModality | undefined,
|
||||||
|
keywords: [...(tool.keywords ?? [])],
|
||||||
|
workflowAliases: workflowAliasesByToolId[tool.id] ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchIndex = TOOLS.map(toToolSearchItem);
|
||||||
|
const defaultTools = defaultToolIds
|
||||||
|
.map((id) => searchIndex.find((tool) => tool.id === id))
|
||||||
|
.filter((tool): tool is ToolSearchItem => Boolean(tool));
|
||||||
|
|
||||||
|
const safeSearchIndexJson = JSON.stringify(searchIndex).replace(/</g, "\\u003c");
|
||||||
|
const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u003c");
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="bg-background-alt px-6 py-20 md:py-28" id="features">
|
<section class="bg-background-alt px-6 py-20 md:py-28" id="features">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="One platform. Every file tool."
|
title="One platform. Every file workflow."
|
||||||
subtitle={`Browse ${toolCount} self-hosted tools across image, video, audio, documents, and data.`}
|
subtitle={`Search ${toolCount} self-hosted tools by task, format, modality, or workflow.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<!-- Modality pills -->
|
<div class="overflow-hidden rounded-2xl border border-border bg-surface shadow-sm">
|
||||||
<div class="flex flex-wrap items-center justify-center gap-2" id="modality-pills">
|
<div class="border-b border-border bg-background px-4 py-4 sm:px-5">
|
||||||
{modalities.map((m) => (
|
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||||
<button
|
<div class="relative">
|
||||||
type="button"
|
<label class="sr-only" for="tool-command-search">Search landing tools</label>
|
||||||
data-modality={m.id}
|
|
||||||
class:list={[
|
|
||||||
"tool-pill shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
|
||||||
m.id === "all"
|
|
||||||
? "bg-primary text-white"
|
|
||||||
: "border border-border bg-surface hover:bg-background-emphasis",
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{m.label} ({m.count})
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category pills -->
|
|
||||||
<div class="mt-3 flex flex-wrap items-center justify-center gap-2" id="category-pills">
|
|
||||||
{CATEGORIES.map((cat) => {
|
|
||||||
const count = TOOLS.filter((t) => t.category === cat.id).length;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-category={cat.id}
|
|
||||||
class="tool-pill shrink-0 rounded-full border border-border bg-surface px-3 py-1 text-xs font-medium transition-colors hover:bg-background-emphasis"
|
|
||||||
>
|
|
||||||
{cat.name} ({count})
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Result count -->
|
|
||||||
<p class="mt-6 text-center text-sm text-muted" id="tool-count">
|
|
||||||
Showing {toolCount} of {toolCount} tools
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Tool grid -->
|
|
||||||
<div class="mt-8 grid gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5" id="tool-grid">
|
|
||||||
{TOOLS.map((tool) => {
|
|
||||||
const cat = categoryMap.get(tool.category);
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={`/tools/${toolSection(tool)}/${tool.id}/`}
|
|
||||||
class="tool-card flex flex-col items-center rounded-xl border border-border bg-surface px-4 py-5 text-center no-underline transition-all"
|
|
||||||
data-name={tool.name.toLowerCase()}
|
|
||||||
data-desc={tool.description.toLowerCase()}
|
|
||||||
data-modality={tool.modality}
|
|
||||||
data-category={tool.category}
|
|
||||||
style={{ "--cat-color": cat?.color } as any}
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
class="mb-2.5 h-7 w-7 shrink-0"
|
class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted"
|
||||||
style={{ color: cat?.color }}
|
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
set:html={renderIcon(tool.icon)}
|
aria-hidden="true"
|
||||||
|
set:html={renderIcon("Search")}
|
||||||
/>
|
/>
|
||||||
<span class="text-sm font-semibold text-foreground">{tool.name}</span>
|
<input
|
||||||
<p class="mt-1 text-xs leading-relaxed text-muted line-clamp-2">{tool.description}</p>
|
id="tool-command-search"
|
||||||
|
type="text"
|
||||||
|
inputmode="search"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-label="Search landing tools"
|
||||||
|
placeholder="Workflow or extension"
|
||||||
|
class="h-13 w-full rounded-lg border border-border bg-surface py-3 pl-12 pr-12 text-base text-foreground outline-none transition-colors placeholder:text-muted focus:border-primary"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
id="tool-command-clear"
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear landing tool search"
|
||||||
|
class="absolute right-3 top-1/2 hidden h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-muted transition-colors hover:bg-background-alt hover:text-foreground"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
set:html={renderIcon("X")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-1 rounded-lg border border-border bg-surface p-1"
|
||||||
|
aria-label="Filter tools by modality"
|
||||||
|
>
|
||||||
|
{modalities.map((modality) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-modality={modality.id}
|
||||||
|
aria-pressed={modality.id === "all" ? "true" : "false"}
|
||||||
|
class:list={[
|
||||||
|
"tool-command-modality rounded-md px-3 py-2 text-sm font-semibold transition-colors",
|
||||||
|
modality.id === "all"
|
||||||
|
? "bg-primary text-white shadow-sm"
|
||||||
|
: "text-muted hover:bg-background-alt hover:text-foreground",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<span>{modality.label}</span>
|
||||||
|
<span class="ms-1 text-xs opacity-75">{modality.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid border-b border-border lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||||
|
<aside class="order-2 border-t border-border bg-background-alt/50 p-4 lg:order-1 lg:border-e lg:border-t-0">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Workflows</p>
|
||||||
|
<button
|
||||||
|
id="tool-command-reset-workflow"
|
||||||
|
type="button"
|
||||||
|
class="hidden rounded-md px-2 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary-subtle"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
||||||
|
{workflows.map((workflow) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-workflow={workflow.query}
|
||||||
|
data-workflow-id={workflow.id}
|
||||||
|
data-label={workflow.label}
|
||||||
|
aria-pressed="false"
|
||||||
|
class="tool-command-workflow group rounded-lg border border-border bg-surface p-3 text-start transition-all hover:border-primary hover:bg-primary-subtle/40"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-3">
|
||||||
|
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-background-alt text-primary transition-colors group-hover:bg-surface">
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
set:html={renderIcon(workflow.icon)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block text-sm font-semibold text-foreground">{workflow.label}</span>
|
||||||
|
<span class="mt-0.5 block text-xs font-medium text-primary-dark">{workflow.metric}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="mt-2 block text-xs leading-relaxed text-muted">{workflow.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="order-1 min-w-0 p-4 sm:p-5 lg:order-2">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tool finder</p>
|
||||||
|
<h3 class="mt-1 text-xl font-bold tracking-tight text-foreground" id="tool-command-title">
|
||||||
|
Suggested starting points
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted" id="tool-command-count" aria-live="polite">
|
||||||
|
{defaultTools.length} curated tools
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||||||
|
id="tool-command-results"
|
||||||
|
>
|
||||||
|
{defaultTools.map((tool) => (
|
||||||
|
<a
|
||||||
|
href={tool.url}
|
||||||
|
class="tool-card group flex min-h-[148px] flex-col rounded-xl border border-border bg-surface p-4 no-underline transition-all"
|
||||||
|
style={`--cat-color:${tool.color};`}
|
||||||
|
>
|
||||||
|
<span class="flex items-start justify-between gap-3">
|
||||||
|
<span
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background-alt"
|
||||||
|
style={`color:${tool.color};`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
set:html={tool.iconSvg}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-background-alt px-2 py-1 text-[11px] font-semibold uppercase text-muted">
|
||||||
|
{tool.modality === "document" ? "PDF" : tool.modality}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="mt-4 text-sm font-semibold text-foreground">{tool.name}</span>
|
||||||
|
<span class="mt-1 line-clamp-2 text-xs leading-relaxed text-muted">{tool.description}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-5 hidden rounded-xl border border-dashed border-border bg-background-alt p-5"
|
||||||
|
id="tool-command-empty"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold tracking-tight text-foreground">No matching tool yet</h3>
|
||||||
|
<p class="mt-2 max-w-2xl text-sm leading-relaxed text-muted">
|
||||||
|
Open a GitHub Discussion with this search prefilled, or browse the closest tools already in SnapOtter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/snapotter-hq/snapotter/discussions/new?category=ideas&title=Tool+request"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
id="tool-command-request-empty"
|
||||||
|
class="btn-primary inline-flex shrink-0 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
Request this tool
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
set:html={renderIcon("ArrowUpRight")}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 hidden">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Closest existing tools</p>
|
||||||
|
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3" id="tool-command-related"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 bg-background px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||||
|
<div class="text-sm text-muted">
|
||||||
|
Missing a workflow?
|
||||||
|
<a
|
||||||
|
href="https://github.com/snapotter-hq/snapotter/discussions/new?category=ideas&title=Tool+request"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
id="tool-command-request"
|
||||||
|
class="font-semibold text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Request a tool
|
||||||
</a>
|
</a>
|
||||||
);
|
and include the input, output, and privacy requirements.
|
||||||
})}
|
</div>
|
||||||
</div>
|
<a href="/tools" class="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:underline">
|
||||||
|
Browse full tool catalog
|
||||||
<!-- Empty state -->
|
<svg
|
||||||
<p class="mt-8 hidden text-center text-muted" id="tool-empty">
|
class="h-4 w-4"
|
||||||
No tools match these filters.
|
viewBox="0 0 24 24"
|
||||||
</p>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
<!-- Browse all link -->
|
stroke-width="2"
|
||||||
<div class="mt-10 text-center">
|
stroke-linecap="round"
|
||||||
<a href="/tools" class="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline">
|
stroke-linejoin="round"
|
||||||
Browse full tool catalog
|
aria-hidden="true"
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
set:html={renderIcon("ArrowRight")}
|
||||||
</a>
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script id="tool-command-index" type="application/json" is:inline set:html={safeSearchIndexJson}></script>
|
||||||
|
<script id="tool-command-default-ids" type="application/json" is:inline set:html={safeDefaultToolIdsJson}></script>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script is:inline>
|
<script>
|
||||||
(function () {
|
import {
|
||||||
var grid = document.getElementById("tool-grid");
|
buildToolRequestDiscussionUrl,
|
||||||
var countEl = document.getElementById("tool-count");
|
searchTools,
|
||||||
var emptyEl = document.getElementById("tool-empty");
|
type ModalityFilter,
|
||||||
var cards = grid.querySelectorAll(".tool-card");
|
type ToolSearchItem,
|
||||||
var total = cards.length;
|
type ToolSearchResult,
|
||||||
var activeModality = "all";
|
} from "../lib/tool-search";
|
||||||
var activeCategory = "";
|
|
||||||
|
|
||||||
function updatePillStyles() {
|
const indexEl = document.getElementById("tool-command-index");
|
||||||
document.querySelectorAll("#modality-pills .tool-pill").forEach(function (btn) {
|
const defaultIdsEl = document.getElementById("tool-command-default-ids");
|
||||||
var m = btn.getAttribute("data-modality");
|
const searchInput = document.getElementById("tool-command-search") as HTMLInputElement | null;
|
||||||
if (m === activeModality) {
|
const clearButton = document.getElementById("tool-command-clear") as HTMLButtonElement | null;
|
||||||
btn.className = "tool-pill shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors bg-primary text-white";
|
const resetWorkflowButton = document.getElementById(
|
||||||
} else {
|
"tool-command-reset-workflow",
|
||||||
btn.className = "tool-pill shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors border border-border bg-surface hover:bg-background-emphasis";
|
) as HTMLButtonElement | null;
|
||||||
}
|
const resultsEl = document.getElementById("tool-command-results");
|
||||||
});
|
const emptyEl = document.getElementById("tool-command-empty");
|
||||||
document.querySelectorAll("#category-pills .tool-pill").forEach(function (btn) {
|
const relatedEl = document.getElementById("tool-command-related");
|
||||||
var c = btn.getAttribute("data-category");
|
const titleEl = document.getElementById("tool-command-title");
|
||||||
if (c === activeCategory) {
|
const countEl = document.getElementById("tool-command-count");
|
||||||
btn.className = "tool-pill shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors bg-primary text-white";
|
const requestLink = document.getElementById("tool-command-request") as HTMLAnchorElement | null;
|
||||||
} else {
|
const requestEmptyLink = document.getElementById(
|
||||||
btn.className = "tool-pill shrink-0 rounded-full border border-border bg-surface px-3 py-1 text-xs font-medium transition-colors hover:bg-background-emphasis";
|
"tool-command-request-empty",
|
||||||
}
|
) as HTMLAnchorElement | null;
|
||||||
|
const modalityButtons = [...document.querySelectorAll<HTMLButtonElement>(".tool-command-modality")];
|
||||||
|
const workflowButtons = [...document.querySelectorAll<HTMLButtonElement>(".tool-command-workflow")];
|
||||||
|
|
||||||
|
if (
|
||||||
|
indexEl?.textContent &&
|
||||||
|
defaultIdsEl?.textContent &&
|
||||||
|
searchInput &&
|
||||||
|
clearButton &&
|
||||||
|
resetWorkflowButton &&
|
||||||
|
resultsEl &&
|
||||||
|
emptyEl &&
|
||||||
|
relatedEl &&
|
||||||
|
titleEl &&
|
||||||
|
countEl
|
||||||
|
) {
|
||||||
|
const searchField = searchInput;
|
||||||
|
const clearSearchButton = clearButton;
|
||||||
|
const resetButton = resetWorkflowButton;
|
||||||
|
const resultContainer = resultsEl;
|
||||||
|
const emptyContainer = emptyEl;
|
||||||
|
const relatedContainer = relatedEl;
|
||||||
|
const titleNode = titleEl;
|
||||||
|
const countNode = countEl;
|
||||||
|
const tools = JSON.parse(indexEl.textContent) as ToolSearchItem[];
|
||||||
|
const defaultToolIds = JSON.parse(defaultIdsEl.textContent) as string[];
|
||||||
|
const defaultToolIdSet = new Set(defaultToolIds);
|
||||||
|
let activeModality: ModalityFilter = "all";
|
||||||
|
let activeWorkflowQuery = "";
|
||||||
|
let activeWorkflowLabel = "";
|
||||||
|
|
||||||
|
const modalityLabels = new Map(
|
||||||
|
modalityButtons.map((button) => [
|
||||||
|
button.dataset.modality as ModalityFilter,
|
||||||
|
button.querySelector("span")?.textContent?.trim() ?? "All",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReason(reason: string): string {
|
||||||
|
return reason
|
||||||
|
.replace("match", "")
|
||||||
|
.replace("token", "")
|
||||||
|
.replace("phrase", "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesActiveModality(tool: ToolSearchItem): boolean {
|
||||||
|
if (activeModality === "all") return true;
|
||||||
|
if (activeModality === "document,file") {
|
||||||
|
return tool.modality === "document" || tool.modality === "file";
|
||||||
|
}
|
||||||
|
return tool.modality === activeModality;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modalityLabel(tool: ToolSearchItem): string {
|
||||||
|
if (tool.modality === "document") return "PDF";
|
||||||
|
return tool.modality;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(resultOrItem: ToolSearchItem | ToolSearchResult, showReason = false): string {
|
||||||
|
const result =
|
||||||
|
"item" in resultOrItem ? resultOrItem : ({ item: resultOrItem, reason: "" } as ToolSearchResult);
|
||||||
|
const { item } = result;
|
||||||
|
const reason = formatReason(result.reason);
|
||||||
|
const reasonHtml =
|
||||||
|
showReason && reason
|
||||||
|
? `<span class="mt-3 inline-flex w-fit rounded-md bg-primary-subtle px-2 py-1 text-[11px] font-semibold text-primary-dark">${escapeHtml(reason)}</span>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<a href="${escapeHtml(item.url)}" class="tool-card group flex min-h-[148px] flex-col rounded-xl border border-border bg-surface p-4 no-underline transition-all" style="--cat-color:${escapeHtml(item.color)};">
|
||||||
|
<span class="flex items-start justify-between gap-3">
|
||||||
|
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background-alt" style="color:${escapeHtml(item.color)};">
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${item.iconSvg}</svg>
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-background-alt px-2 py-1 text-[11px] font-semibold uppercase text-muted">${escapeHtml(modalityLabel(item))}</span>
|
||||||
|
</span>
|
||||||
|
<span class="mt-4 text-sm font-semibold text-foreground">${escapeHtml(item.name)}</span>
|
||||||
|
<span class="mt-1 line-clamp-2 text-xs leading-relaxed text-muted">${escapeHtml(item.description)}</span>
|
||||||
|
${reasonHtml}
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRequestLinks(query: string): void {
|
||||||
|
const href = buildToolRequestDiscussionUrl(query);
|
||||||
|
if (requestLink) requestLink.href = href;
|
||||||
|
if (requestEmptyLink) requestEmptyLink.href = href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClearButton(hasQuery: boolean): void {
|
||||||
|
clearSearchButton.classList.toggle("hidden", !hasQuery);
|
||||||
|
clearSearchButton.classList.toggle("inline-flex", hasQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveModality(nextModality: ModalityFilter): void {
|
||||||
|
activeModality = nextModality;
|
||||||
|
for (const button of modalityButtons) {
|
||||||
|
const isActive = button.dataset.modality === activeModality;
|
||||||
|
button.setAttribute("aria-pressed", String(isActive));
|
||||||
|
button.classList.toggle("bg-primary", isActive);
|
||||||
|
button.classList.toggle("text-white", isActive);
|
||||||
|
button.classList.toggle("shadow-sm", isActive);
|
||||||
|
button.classList.toggle("text-muted", !isActive);
|
||||||
|
button.classList.toggle("hover:bg-background-alt", !isActive);
|
||||||
|
button.classList.toggle("hover:text-foreground", !isActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveWorkflow(query: string, label: string): void {
|
||||||
|
activeWorkflowQuery = query;
|
||||||
|
activeWorkflowLabel = label;
|
||||||
|
for (const button of workflowButtons) {
|
||||||
|
const isActive = button.dataset.workflow === activeWorkflowQuery;
|
||||||
|
button.setAttribute("aria-pressed", String(isActive));
|
||||||
|
button.classList.toggle("border-primary", isActive);
|
||||||
|
button.classList.toggle("bg-primary-subtle", isActive);
|
||||||
|
button.classList.toggle("border-border", !isActive);
|
||||||
|
button.classList.toggle("bg-surface", !isActive);
|
||||||
|
}
|
||||||
|
resetButton.classList.toggle("hidden", !activeWorkflowQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchQuery(): string {
|
||||||
|
return searchField.value.trim() || activeWorkflowQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideEmptyState(): void {
|
||||||
|
emptyContainer.classList.add("hidden");
|
||||||
|
relatedContainer.innerHTML = "";
|
||||||
|
relatedContainer.parentElement?.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDefault(): void {
|
||||||
|
const defaultTools = tools.filter(
|
||||||
|
(tool) => defaultToolIdSet.has(tool.id) && matchesActiveModality(tool),
|
||||||
|
);
|
||||||
|
const fallbackTools =
|
||||||
|
defaultTools.length > 0 ? defaultTools : tools.filter(matchesActiveModality).slice(0, 12);
|
||||||
|
const modalityName = modalityLabels.get(activeModality) ?? "All";
|
||||||
|
|
||||||
|
titleNode.textContent =
|
||||||
|
activeModality === "all" ? "Suggested starting points" : `${modalityName} starting points`;
|
||||||
|
countNode.textContent = `${fallbackTools.length} curated tools`;
|
||||||
|
resultContainer.innerHTML = fallbackTools.map((tool) => renderCard(tool)).join("");
|
||||||
|
resultContainer.classList.remove("hidden");
|
||||||
|
hideEmptyState();
|
||||||
|
setRequestLinks("");
|
||||||
|
updateClearButton(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSearch(): void {
|
||||||
|
const query = searchQuery();
|
||||||
|
const hasQuery = query.length > 0;
|
||||||
|
updateClearButton(hasQuery);
|
||||||
|
setRequestLinks(query);
|
||||||
|
|
||||||
|
if (!hasQuery) {
|
||||||
|
renderDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = searchTools(tools, { query, modality: activeModality, limit: 12 });
|
||||||
|
|
||||||
|
if (response.hasConfidentMatch && response.results.length > 0) {
|
||||||
|
titleNode.textContent = activeWorkflowLabel || "Recommended tools";
|
||||||
|
countNode.textContent = `${response.results.length} of ${response.totalMatches} matches`;
|
||||||
|
resultContainer.innerHTML = response.results.map((result) => renderCard(result, true)).join("");
|
||||||
|
resultContainer.classList.remove("hidden");
|
||||||
|
hideEmptyState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
titleNode.textContent = "Request or refine";
|
||||||
|
countNode.textContent =
|
||||||
|
response.related.length > 0
|
||||||
|
? `${response.related.length} close ${response.related.length === 1 ? "tool" : "tools"}`
|
||||||
|
: "No confident match";
|
||||||
|
resultContainer.classList.add("hidden");
|
||||||
|
emptyContainer.classList.remove("hidden");
|
||||||
|
relatedContainer.innerHTML = response.related.map((result) => renderCard(result, true)).join("");
|
||||||
|
relatedContainer.parentElement?.classList.toggle("hidden", response.related.length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchField.addEventListener("input", () => {
|
||||||
|
if (searchField.value.trim() !== activeWorkflowQuery) {
|
||||||
|
setActiveWorkflow("", "");
|
||||||
|
}
|
||||||
|
renderSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
clearSearchButton.addEventListener("click", () => {
|
||||||
|
searchField.value = "";
|
||||||
|
setActiveWorkflow("", "");
|
||||||
|
searchField.focus();
|
||||||
|
renderDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
resetButton.addEventListener("click", () => {
|
||||||
|
searchField.value = "";
|
||||||
|
setActiveWorkflow("", "");
|
||||||
|
renderDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const button of modalityButtons) {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
setActiveModality((button.dataset.modality as ModalityFilter) ?? "all");
|
||||||
|
renderSearch();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function filter() {
|
for (const button of workflowButtons) {
|
||||||
var visible = 0;
|
button.addEventListener("click", () => {
|
||||||
cards.forEach(function (card) {
|
const query = button.dataset.workflow ?? "";
|
||||||
var mod = card.getAttribute("data-modality") || "";
|
const label = button.dataset.label ?? "";
|
||||||
var cat = card.getAttribute("data-category") || "";
|
searchField.value = "";
|
||||||
var matchMod = activeModality === "all" || activeModality.split(",").indexOf(mod) !== -1;
|
setActiveWorkflow(activeWorkflowQuery === query ? "" : query, activeWorkflowQuery === query ? "" : label);
|
||||||
var matchCat = !activeCategory || cat === activeCategory;
|
renderSearch();
|
||||||
if (matchMod && matchCat) {
|
|
||||||
card.style.display = "";
|
|
||||||
visible++;
|
|
||||||
} else {
|
|
||||||
card.style.display = "none";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
countEl.textContent = "Showing " + visible + " of " + total + " tools";
|
|
||||||
emptyEl.style.display = visible === 0 ? "block" : "none";
|
|
||||||
grid.style.display = visible === 0 ? "none" : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll("#modality-pills .tool-pill").forEach(function (btn) {
|
renderDefault();
|
||||||
btn.addEventListener("click", function () {
|
}
|
||||||
activeModality = btn.getAttribute("data-modality");
|
|
||||||
updatePillStyles();
|
|
||||||
filter();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll("#category-pills .tool-pill").forEach(function (btn) {
|
|
||||||
btn.addEventListener("click", function () {
|
|
||||||
var cat = btn.getAttribute("data-category");
|
|
||||||
activeCategory = activeCategory === cat ? "" : cat;
|
|
||||||
updatePillStyles();
|
|
||||||
filter();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -56,10 +56,30 @@ test.describe("Landing Homepage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("tool catalog section renders heading and browse link", async ({ page }) => {
|
test("tool catalog section renders heading and browse link", async ({ page }) => {
|
||||||
await expect(page.getByText("One platform. Every file tool.")).toBeVisible();
|
await expect(page.getByText("One platform. Every file workflow.")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Search landing tools")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Optimize delivery" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: /Request a tool/ })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Browse full tool catalog" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Browse full tool catalog" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("tool command center searches, filters, and links missing requests", async ({ page }) => {
|
||||||
|
const search = page.getByLabel("Search landing tools");
|
||||||
|
|
||||||
|
await search.fill("mp4 to mp3");
|
||||||
|
await expect(
|
||||||
|
page.locator("#tool-command-results").getByRole("link", { name: /MP4 to MP3/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("Recommended tools")).toBeVisible();
|
||||||
|
|
||||||
|
await search.fill("convert figma file to layered psd");
|
||||||
|
await expect(page.getByText("No matching tool yet")).toBeVisible();
|
||||||
|
|
||||||
|
const requestHref = await page.locator("#tool-command-request-empty").getAttribute("href");
|
||||||
|
expect(requestHref).toContain("github.com/snapotter-hq/snapotter/discussions/new");
|
||||||
|
expect(requestHref).toContain("title=Tool+request%3A+convert+figma+file+to+layered+psd");
|
||||||
|
});
|
||||||
|
|
||||||
test("enterprise section renders eyebrow and feature cards", async ({ page }) => {
|
test("enterprise section renders eyebrow and feature cards", async ({ page }) => {
|
||||||
await expect(page.getByText("Built for enterprise deployment.")).toBeVisible();
|
await expect(page.getByText("Built for enterprise deployment.")).toBeVisible();
|
||||||
await expect(page.getByText("Enterprise-grade security")).toBeVisible();
|
await expect(page.getByText("Enterprise-grade security")).toBeVisible();
|
||||||
|
|||||||
@@ -1,27 +1,596 @@
|
|||||||
|
import { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { matchTool } from "../../../apps/landing/src/lib/tool-search.js";
|
import {
|
||||||
|
buildToolRequestDiscussionUrl,
|
||||||
|
matchTool,
|
||||||
|
scoreTool,
|
||||||
|
searchTools,
|
||||||
|
type ToolSearchItem,
|
||||||
|
} from "../../../apps/landing/src/lib/tool-search.js";
|
||||||
|
|
||||||
// haystack = name + " " + desc + " " + keywords (all lowercase, space-joined)
|
|
||||||
const jpgPng = {
|
const jpgPng = {
|
||||||
id: "jpg-to-png",
|
id: "jpg-to-png",
|
||||||
haystack: "jpg to png convert jpg to png jpg jpeg png jpg2png jpgtopng",
|
haystack: "jpg to png convert jpg to png jpg jpeg png",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tools: ToolSearchItem[] = [
|
||||||
|
{
|
||||||
|
id: "compress",
|
||||||
|
name: "Compress",
|
||||||
|
description: "Reduce image file size by quality or target size",
|
||||||
|
modality: "image",
|
||||||
|
category: "optimization",
|
||||||
|
url: "/tools/image/compress/",
|
||||||
|
icon: "Minimize2",
|
||||||
|
iconSvg: "",
|
||||||
|
color: "#10B981",
|
||||||
|
acceptedInputs: ["image/jpeg", "image/png"],
|
||||||
|
outputModality: "image",
|
||||||
|
keywords: ["image compression", "jpg size reducer", "optimize image"],
|
||||||
|
workflowAliases: ["optimize web", "reduce image size"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "compress-pdf",
|
||||||
|
name: "Compress PDF",
|
||||||
|
description: "Reduce PDF file size while preserving readability",
|
||||||
|
modality: "document",
|
||||||
|
category: "pdf-optimize",
|
||||||
|
url: "/tools/pdf/compress-pdf/",
|
||||||
|
icon: "Minimize2",
|
||||||
|
iconSvg: "",
|
||||||
|
color: "#10B981",
|
||||||
|
acceptedInputs: ["application/pdf", "pdf"],
|
||||||
|
outputModality: "document",
|
||||||
|
keywords: ["pdf compression", "reduce pdf size", "compress document"],
|
||||||
|
workflowAliases: ["optimize pdf", "reduce pdf size"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "convert-document",
|
||||||
|
name: "Convert Document",
|
||||||
|
description: "Convert PDF and document files between formats",
|
||||||
|
modality: "document",
|
||||||
|
category: "document-conversion",
|
||||||
|
url: "/tools/pdf/convert-document/",
|
||||||
|
icon: "FileOutput",
|
||||||
|
iconSvg: "",
|
||||||
|
color: "#3B82F6",
|
||||||
|
acceptedInputs: ["application/pdf", "pdf", "docx"],
|
||||||
|
outputModality: "document",
|
||||||
|
keywords: ["pdf converter", "document converter", "change document format"],
|
||||||
|
workflowAliases: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inspect-pdf",
|
||||||
|
name: "Inspect PDF",
|
||||||
|
description: "Inspect PDF metadata and page information",
|
||||||
|
modality: "document",
|
||||||
|
category: "pdf-inspect",
|
||||||
|
url: "/tools/pdf/inspect-pdf/",
|
||||||
|
icon: "Info",
|
||||||
|
iconSvg: "",
|
||||||
|
color: "#6366F1",
|
||||||
|
acceptedInputs: ["application/pdf", "pdf"],
|
||||||
|
outputModality: "document",
|
||||||
|
keywords: ["pdf metadata", "document properties"],
|
||||||
|
workflowAliases: ["inspect document"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "convert",
|
||||||
|
name: "Convert",
|
||||||
|
description: "Convert between image formats",
|
||||||
|
modality: "image",
|
||||||
|
category: "essentials",
|
||||||
|
url: "/tools/image/convert/",
|
||||||
|
icon: "FileOutput",
|
||||||
|
iconSvg: "",
|
||||||
|
color: "#3B82F6",
|
||||||
|
acceptedInputs: ["image/jpeg", "image/png", "jpg", "png", "webp"],
|
||||||
|
outputModality: "image",
|
||||||
|
keywords: ["jpg to png", "image converter"],
|
||||||
|
workflowAliases: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "remove-background",
|
||||||
|
name: "Remove Background",
|
||||||
|
description: "Remove image backgrounds and export transparent PNG files",
|
||||||
|
modality: "image",
|
||||||
|
category: "enhance",
|
||||||
|
url: "/tools/image/remove-background/",
|
||||||
|
icon: "Sparkles",
|
||||||
|
iconSvg: "",
|
||||||
|
color: "#F59E0B",
|
||||||
|
acceptedInputs: ["image/jpeg", "image/png"],
|
||||||
|
outputModality: "image",
|
||||||
|
keywords: ["remove bg", "cutout", "transparent background", "transparent png"],
|
||||||
|
workflowAliases: ["enhance ai"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trim-video",
|
||||||
|
name: "Trim Video",
|
||||||
|
description: "Cut video clips by start and end time",
|
||||||
|
modality: "video",
|
||||||
|
category: "video-edit",
|
||||||
|
url: "/tools/video/trim-video/",
|
||||||
|
icon: "Scissors",
|
||||||
|
iconSvg: "",
|
||||||
|
color: "#EF4444",
|
||||||
|
acceptedInputs: ["video/mp4", "mp4"],
|
||||||
|
outputModality: "video",
|
||||||
|
keywords: ["cut video", "shorten mp4"],
|
||||||
|
workflowAliases: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inspect-psd",
|
||||||
|
name: "Inspect PSD",
|
||||||
|
description: "Inspect Photoshop document metadata and layers",
|
||||||
|
modality: "image",
|
||||||
|
category: "image-inspect",
|
||||||
|
url: "/tools/image/inspect-psd/",
|
||||||
|
icon: "Info",
|
||||||
|
iconSvg: "",
|
||||||
|
color: "#6366F1",
|
||||||
|
acceptedInputs: ["image/vnd.adobe.photoshop", "psd"],
|
||||||
|
outputModality: "image",
|
||||||
|
keywords: ["psd metadata", "photoshop layers"],
|
||||||
|
workflowAliases: ["inspect psd"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getToolFixture(id: string): ToolSearchItem {
|
||||||
|
const tool = tools.find((entry) => entry.id === id);
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error(`Missing tool fixture: ${id}`);
|
||||||
|
}
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realTools: ToolSearchItem[] = TOOLS.map((tool) => {
|
||||||
|
const category = CATEGORIES.find((entry) => entry.id === tool.category);
|
||||||
|
return {
|
||||||
|
id: tool.id,
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
modality: tool.modality,
|
||||||
|
category: tool.category,
|
||||||
|
url: `/tools/${toolSection(tool)}/${tool.id}/`,
|
||||||
|
icon: tool.icon,
|
||||||
|
iconSvg: "",
|
||||||
|
color: category?.color ?? "#6B7280",
|
||||||
|
acceptedInputs: tool.acceptedInputs,
|
||||||
|
outputModality: tool.outputModality,
|
||||||
|
keywords: tool.keywords,
|
||||||
|
workflowAliases: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const genericConverterIds = ["convert", "convert-audio", "convert-video", "convert-spreadsheet"];
|
||||||
|
const pdfConversionIds = new Set([
|
||||||
|
"pdf-to-word",
|
||||||
|
"pdf-to-jpg",
|
||||||
|
"pdf-to-png",
|
||||||
|
"pdf-to-tiff",
|
||||||
|
"pdf-to-image",
|
||||||
|
]);
|
||||||
|
const psdConversionIds = new Set(["psd-to-jpg", "psd-to-png", "psd-to-svg"]);
|
||||||
|
const toPdfConversionIds = new Set([
|
||||||
|
"jpg-to-pdf",
|
||||||
|
"png-to-pdf",
|
||||||
|
"heic-to-pdf",
|
||||||
|
"tiff-to-pdf",
|
||||||
|
"webp-to-pdf",
|
||||||
|
"gif-to-pdf",
|
||||||
|
"eps-to-pdf",
|
||||||
|
"word-to-pdf",
|
||||||
|
"excel-to-pdf",
|
||||||
|
"powerpoint-to-pdf",
|
||||||
|
"html-to-pdf",
|
||||||
|
"markdown-to-pdf",
|
||||||
|
"image-to-pdf",
|
||||||
|
]);
|
||||||
|
const fromPdfConversionIds = new Set(["pdf-to-word", "pdf-to-jpg", "pdf-to-png", "pdf-to-tiff"]);
|
||||||
|
const videoToAudioIds = new Set([
|
||||||
|
"extract-audio",
|
||||||
|
"mp4-to-mp3",
|
||||||
|
"mov-to-mp3",
|
||||||
|
"mkv-to-mp3",
|
||||||
|
"webm-to-mp3",
|
||||||
|
"avi-to-mp3",
|
||||||
|
"mp4-to-wav",
|
||||||
|
"mov-to-wav",
|
||||||
|
"mp4-to-ogg",
|
||||||
|
"convert-audio",
|
||||||
|
]);
|
||||||
|
|
||||||
describe("landing matchTool", () => {
|
describe("landing matchTool", () => {
|
||||||
it.each([
|
it.each(["jpeg to png", "jpg2png", "jpg to png", "covert jpg to png"])("matches %s", (q) => {
|
||||||
"jpeg to png",
|
|
||||||
"jpg2png",
|
|
||||||
"jpg to png",
|
|
||||||
"covert jpg to png", // misspelling of convert
|
|
||||||
])("matches %s", (q) => {
|
|
||||||
expect(matchTool(q, jpgPng.haystack)).toBe(true);
|
expect(matchTool(q, jpgPng.haystack)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("typo within one edit still matches via fallback (jpge)", () => {
|
it("typo within one edit still matches via fallback", () => {
|
||||||
expect(matchTool("jpge to png", jpgPng.haystack)).toBe(true);
|
expect(matchTool("jpge to png", jpgPng.haystack)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unrelated query", () => {
|
it("rejects unrelated query", () => {
|
||||||
expect(matchTool("trim video", jpgPng.haystack)).toBe(false);
|
expect(matchTool("trim video", jpgPng.haystack)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires exact matches for known short file formats", () => {
|
||||||
|
expect(matchTool("jpg2png", "jpg2png")).toBe(true);
|
||||||
|
expect(matchTool("mp4 to mp3", "convert video mp4 mov mkv avi webm")).toBe(false);
|
||||||
|
expect(
|
||||||
|
matchTool("mp4 to mp3", "m4a to mp3 aac to mp3 wav to mp3 ogg to mp3 audio converter"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(matchTool("mp4 to mp3", "mp4 to mp3 extract audio from mp4 as mp3")).toBe(true);
|
||||||
|
expect(matchTool("add text to image", "add text captions and labels to images")).toBe(true);
|
||||||
|
expect(matchTool("pdf to jpg", "convert image jpg png webp")).toBe(false);
|
||||||
|
expect(matchTool("pdf to jpg", "jpg to pdf convert jpg images to pdf")).toBe(false);
|
||||||
|
expect(matchTool("pdf to jpg", "pdf to jpg convert pdf pages to images")).toBe(true);
|
||||||
|
expect(matchTool("aviftopng", "png to avif convert png images to avif")).toBe(false);
|
||||||
|
expect(matchTool("aviftopng", "avif to png convert avif images to png")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("landing scoreTool", () => {
|
||||||
|
it("scores exact or near-exact tool name matches with a reason", () => {
|
||||||
|
const result = scoreTool("remove background", getToolFixture("remove-background"));
|
||||||
|
expect(result.score).toBeGreaterThan(0);
|
||||||
|
expect(result.reason).toMatch(/name|keyword/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks conversion shorthand through normalized keywords", () => {
|
||||||
|
const result = searchTools(tools, { query: "jpg2png", modality: "all", limit: 5 });
|
||||||
|
expect(result.results[0]?.item.id).toBe("convert");
|
||||||
|
expect(result.results[0]?.score).toBeGreaterThan(0);
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks PDF compression above generic image compression", () => {
|
||||||
|
const result = searchTools(tools, { query: "compress pdf", modality: "all", limit: 5 });
|
||||||
|
expect(result.results[0]?.item.id).toBe("compress-pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves conversion intent when ranking PDF tools", () => {
|
||||||
|
const result = searchTools(tools, { query: "convert pdf", modality: "all", limit: 5 });
|
||||||
|
expect(result.results[0]?.item.id).toBe("convert-document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves conversion intent when ranking document tools", () => {
|
||||||
|
const result = searchTools(tools, { query: "convert document", modality: "all", limit: 5 });
|
||||||
|
expect(result.results[0]?.item.id).toBe("convert-document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unsupported conversion searches away from unrelated confident first results", () => {
|
||||||
|
const result = searchTools(tools, { query: "convert psd", modality: "all", limit: 5 });
|
||||||
|
expect(result.results.map((entry) => entry.item.id)).not.toContain("inspect-psd");
|
||||||
|
expect(result.hasConfidentMatch).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("understands common task aliases", () => {
|
||||||
|
const result = searchTools(tools, {
|
||||||
|
query: "remove bg transparent",
|
||||||
|
modality: "all",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
expect(result.results[0]?.item.id).toBe("remove-background");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hard-filters by modality after scoring", () => {
|
||||||
|
const result = searchTools(tools, { query: "compress", modality: "document", limit: 5 });
|
||||||
|
expect(result.results.map((entry) => entry.item.id)).toEqual(["compress-pdf"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no confident match for unrelated searches", () => {
|
||||||
|
const result = searchTools(tools, {
|
||||||
|
query: "convert figma file to layered psd",
|
||||||
|
modality: "all",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
expect(result.results).toHaveLength(0);
|
||||||
|
expect(result.hasConfidentMatch).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("landing searchTools with real catalog metadata", () => {
|
||||||
|
it("ranks PDF-specific conversion before generic converters for convert pdf", () => {
|
||||||
|
const result = searchTools(realTools, { query: "convert pdf", modality: "all", limit: 8 });
|
||||||
|
const firstId = result.results[0]?.item.id;
|
||||||
|
|
||||||
|
expect(firstId).toBeDefined();
|
||||||
|
expect(firstId?.startsWith("pdf-to-") || pdfConversionIds.has(firstId ?? "")).toBe(true);
|
||||||
|
expect(genericConverterIds).not.toContain(firstId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks document conversion before generic converters for convert document", () => {
|
||||||
|
const result = searchTools(realTools, { query: "convert document", modality: "all", limit: 8 });
|
||||||
|
const rankedIds = result.results.map((entry) => entry.item.id);
|
||||||
|
|
||||||
|
expect(rankedIds[0]).toBe("convert-document");
|
||||||
|
for (const genericId of genericConverterIds) {
|
||||||
|
const genericIndex = rankedIds.indexOf(genericId);
|
||||||
|
if (genericIndex !== -1) {
|
||||||
|
expect(genericIndex).toBeGreaterThan(rankedIds.indexOf("convert-document"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks PSD-specific conversion before generic converters for convert psd", () => {
|
||||||
|
const result = searchTools(realTools, { query: "convert psd", modality: "all", limit: 8 });
|
||||||
|
const firstId = result.results[0]?.item.id;
|
||||||
|
|
||||||
|
expect(firstId).toBeDefined();
|
||||||
|
expect(psdConversionIds.has(firstId ?? "")).toBe(true);
|
||||||
|
expect(genericConverterIds).not.toContain(firstId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks destination-to-PDF tools before PDF source converters for convert to pdf", () => {
|
||||||
|
const result = searchTools(realTools, { query: "convert to pdf", modality: "all", limit: 8 });
|
||||||
|
const firstId = result.results[0]?.item.id;
|
||||||
|
|
||||||
|
expect(firstId).toBeDefined();
|
||||||
|
expect(firstId?.endsWith("-to-pdf") || toPdfConversionIds.has(firstId ?? "")).toBe(true);
|
||||||
|
expect(fromPdfConversionIds.has(firstId ?? "")).toBe(false);
|
||||||
|
expect(genericConverterIds).not.toContain(firstId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit PDF source direction for pdf to jpg", () => {
|
||||||
|
const result = searchTools(realTools, { query: "pdf to jpg", modality: "all", limit: 8 });
|
||||||
|
|
||||||
|
expect(result.results[0]?.item.id).toBe("pdf-to-jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create confident results for same-format alias conversions", () => {
|
||||||
|
const result = searchTools(realTools, { query: "jpg to jpeg", modality: "all", limit: 8 });
|
||||||
|
|
||||||
|
expect(result.results).toHaveLength(0);
|
||||||
|
expect(result.hasConfidentMatch).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns confident results for document extension conversions", () => {
|
||||||
|
const result = searchTools(realTools, { query: "doc to docx", modality: "all", limit: 8 });
|
||||||
|
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
expect(result.results[0]?.item.id).toBe("convert-document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns confident results for presentation extension conversions", () => {
|
||||||
|
const result = searchTools(realTools, { query: "ppt to pptx", modality: "all", limit: 8 });
|
||||||
|
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
expect(result.results[0]?.item.id).toBe("convert-presentation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns confident results for spreadsheet extension conversions", () => {
|
||||||
|
const result = searchTools(realTools, { query: "xls to xlsx", modality: "all", limit: 8 });
|
||||||
|
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
expect(result.results[0]?.item.id).toBe("convert-spreadsheet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns confident results for broad video to audio conversion", () => {
|
||||||
|
const result = searchTools(realTools, {
|
||||||
|
query: "convert video to audio",
|
||||||
|
modality: "all",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
expect(videoToAudioIds.has(result.results[0]?.item.id ?? "")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns confident results for MP4 to audio conversion", () => {
|
||||||
|
const result = searchTools(realTools, {
|
||||||
|
query: "mp4 to audio",
|
||||||
|
modality: "all",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
expect(videoToAudioIds.has(result.results[0]?.item.id ?? "")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats audio from video as video to audio conversion", () => {
|
||||||
|
const result = searchTools(realTools, {
|
||||||
|
query: "convert audio from video",
|
||||||
|
modality: "all",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
const firstId = result.results[0]?.item.id;
|
||||||
|
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
expect(videoToAudioIds.has(firstId ?? "")).toBe(true);
|
||||||
|
expect(firstId).not.toBe("video-to-webp");
|
||||||
|
expect(firstId).not.toBe("video-to-gif");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats mp3 from mp4 as mp4 to mp3 conversion", () => {
|
||||||
|
const result = searchTools(realTools, {
|
||||||
|
query: "convert mp3 from mp4",
|
||||||
|
modality: "all",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
const rankedIds = result.results.map((entry) => entry.item.id);
|
||||||
|
const mp4ToMp3Index = rankedIds.indexOf("mp4-to-mp3");
|
||||||
|
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
expect(["mp4-to-mp3", "extract-audio", "convert-audio"]).toContain(result.results[0]?.item.id);
|
||||||
|
expect(mp4ToMp3Index).not.toBe(-1);
|
||||||
|
for (const unrelatedId of ["mp4-to-wav", "mp4-to-ogg", "mp4-to-aac", "mp4-to-flac"]) {
|
||||||
|
const unrelatedIndex = rankedIds.indexOf(unrelatedId);
|
||||||
|
if (unrelatedIndex !== -1) {
|
||||||
|
expect(unrelatedIndex).toBeGreaterThan(mp4ToMp3Index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns confident results for broad video to MP4 conversion", () => {
|
||||||
|
const result = searchTools(realTools, {
|
||||||
|
query: "convert video to mp4",
|
||||||
|
modality: "all",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
const firstId = result.results[0]?.item.id;
|
||||||
|
const rankedIds = result.results.map((entry) => entry.item.id);
|
||||||
|
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
expect(firstId === "convert-video" || firstId?.endsWith("-to-mp4")).toBe(true);
|
||||||
|
expect(rankedIds).not.toContain("compress-video");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns confident results for broad audio to MP3 conversion", () => {
|
||||||
|
const result = searchTools(realTools, {
|
||||||
|
query: "convert audio to mp3",
|
||||||
|
modality: "all",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
const firstId = result.results[0]?.item.id;
|
||||||
|
const rankedIds = result.results.map((entry) => entry.item.id);
|
||||||
|
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
expect(firstId === "convert-audio" || firstId?.endsWith("-to-mp3")).toBe(true);
|
||||||
|
expect(rankedIds).not.toContain("transcribe-audio");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns confident results for broad image to PDF conversion", () => {
|
||||||
|
const result = searchTools(realTools, {
|
||||||
|
query: "convert images to pdf",
|
||||||
|
modality: "all",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
const firstId = result.results[0]?.item.id;
|
||||||
|
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
expect(firstId === "image-to-pdf" || firstId?.endsWith("-to-pdf")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns confident results for broad document to PDF conversion", () => {
|
||||||
|
const result = searchTools(realTools, {
|
||||||
|
query: "convert documents to pdf",
|
||||||
|
modality: "all",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
const firstId = result.results[0]?.item.id;
|
||||||
|
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
expect(
|
||||||
|
firstId === "convert-document" ||
|
||||||
|
[
|
||||||
|
"word-to-pdf",
|
||||||
|
"excel-to-pdf",
|
||||||
|
"powerpoint-to-pdf",
|
||||||
|
"html-to-pdf",
|
||||||
|
"markdown-to-pdf",
|
||||||
|
].includes(firstId ?? ""),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps broad image convert available for bmp to png", () => {
|
||||||
|
const result = searchTools(realTools, { query: "bmp to png", modality: "all", limit: 5 });
|
||||||
|
const rankedIds = result.results.map((entry) => entry.item.id);
|
||||||
|
|
||||||
|
expect(result.results[0]?.item.id).toBe("convert");
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
expect(rankedIds).not.toContain("ocr");
|
||||||
|
expect(rankedIds).not.toContain("colorize");
|
||||||
|
expect(rankedIds).not.toContain("optimize-for-web");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not rank reversed AVIF conversion first for avif to png", () => {
|
||||||
|
const result = searchTools(realTools, { query: "avif to png", modality: "all", limit: 5 });
|
||||||
|
|
||||||
|
expect(result.results[0]?.item.id).toBe("convert");
|
||||||
|
expect(result.results[0]?.item.id).not.toBe("png-to-avif");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes compact AVIF conversion aliases before direction ranking", () => {
|
||||||
|
const result = searchTools(realTools, { query: "avif2png", modality: "all", limit: 5 });
|
||||||
|
const rankedIds = result.results.map((entry) => entry.item.id);
|
||||||
|
|
||||||
|
expect(result.results[0]?.item.id).toBe("convert");
|
||||||
|
expect(result.results[0]?.item.id).not.toBe("png-to-avif");
|
||||||
|
expect(rankedIds).not.toContain("ocr");
|
||||||
|
expect(rankedIds).not.toContain("colorize");
|
||||||
|
expect(rankedIds).not.toContain("optimize-for-web");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat remove background from image as conversion intent", () => {
|
||||||
|
const result = searchTools(realTools, {
|
||||||
|
query: "remove background from image",
|
||||||
|
modality: "all",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.results[0]?.item.id).toBe("remove-background");
|
||||||
|
expect(result.hasConfidentMatch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat add text to image as conversion intent", () => {
|
||||||
|
const result = searchTools(realTools, {
|
||||||
|
query: "add text to image",
|
||||||
|
modality: "all",
|
||||||
|
limit: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.results[0]?.item.id).toBe("text-overlay");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat add watermark to pdf as conversion intent", () => {
|
||||||
|
const result = searchTools(realTools, {
|
||||||
|
query: "add watermark to pdf",
|
||||||
|
modality: "all",
|
||||||
|
limit: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.results[0]?.item.id).toBe("watermark-pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat extract pages from pdf as conversion intent", () => {
|
||||||
|
const result = searchTools(realTools, {
|
||||||
|
query: "extract pages from pdf",
|
||||||
|
modality: "all",
|
||||||
|
limit: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.results[0]?.item.id).toBe("extract-pages");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildToolRequestDiscussionUrl", () => {
|
||||||
|
it("builds an Ideas discussion URL with encoded title and body", () => {
|
||||||
|
const url = new URL(buildToolRequestDiscussionUrl("convert figma file to layered psd"));
|
||||||
|
expect(url.origin + url.pathname).toBe(
|
||||||
|
"https://github.com/snapotter-hq/snapotter/discussions/new",
|
||||||
|
);
|
||||||
|
expect(url.searchParams.get("category")).toBe("ideas");
|
||||||
|
expect(url.searchParams.get("title")).toBe("Tool request: convert figma file to layered psd");
|
||||||
|
expect(url.searchParams.get("body")).toContain(
|
||||||
|
"I searched SnapOtter for:\n\n> convert figma file to layered psd",
|
||||||
|
);
|
||||||
|
expect(url.searchParams.get("body")).not.toContain("I searched SnapOtter for: `");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips title newlines and collapses whitespace", () => {
|
||||||
|
const url = new URL(buildToolRequestDiscussionUrl(" convert\n\nfoo\tto bar "));
|
||||||
|
expect(url.searchParams.get("title")).toBe("Tool request: convert foo to bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates long title queries", () => {
|
||||||
|
const long = "x".repeat(200);
|
||||||
|
const url = new URL(buildToolRequestDiscussionUrl(long));
|
||||||
|
expect(url.searchParams.get("title")).toBe(`Tool request: ${"x".repeat(120)}`);
|
||||||
|
expect(url.searchParams.get("body")).toContain("x".repeat(200));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a generic title for empty queries", () => {
|
||||||
|
const url = new URL(buildToolRequestDiscussionUrl(" "));
|
||||||
|
expect(url.searchParams.get("title")).toBe("Tool request");
|
||||||
|
expect(url.searchParams.get("body")).not.toContain("I searched SnapOtter for:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders backtick queries without an inline code span", () => {
|
||||||
|
const url = new URL(buildToolRequestDiscussionUrl("convert `pdf` to docx"));
|
||||||
|
expect(url.searchParams.get("body")).toContain(
|
||||||
|
"I searched SnapOtter for:\n\n> convert `pdf` to docx",
|
||||||
|
);
|
||||||
|
expect(url.searchParams.get("body")).not.toContain("I searched SnapOtter for: `");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user