Skip to content

Commit 8365e41

Browse files
authored
Merge pull request #110 from slaveofcode/feat/seo-rich-content
feat(seo): rich per-tool content + category hubs (all 75 tools)
2 parents ee7a7cd + debf0d2 commit 8365e41

5 files changed

Lines changed: 1510 additions & 13 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
import { ChevronRight } from 'lucide-react';
3+
import Base from '@/layouts/Base.astro';
4+
import { tools } from '@/registry/tools';
5+
import { categories, categoryColors, categoryDescriptions, categorySlug } from '@/registry/categories';
6+
import { SITE_URL, SITE_NAME } from '@/config';
7+
import type { Category } from '@/types/tool';
8+
9+
export function getStaticPaths() {
10+
return categories
11+
.filter(category => tools.some(t => t.category === category && !t.desktopOnly))
12+
.map(category => ({ params: { category: categorySlug(category) }, props: { category } }));
13+
}
14+
15+
const { category } = Astro.props as { category: Category };
16+
const categoryTools = tools.filter(t => t.category === category && !t.desktopOnly);
17+
const pageTitle = `${category} Tools`;
18+
const description = categoryDescriptions[category];
19+
const pageUrl = new URL(`/category/${categorySlug(category)}`, SITE_URL).href;
20+
21+
const breadcrumbJsonLd = {
22+
'@context': 'https://schema.org',
23+
'@type': 'BreadcrumbList',
24+
itemListElement: [
25+
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL + '/' },
26+
{ '@type': 'ListItem', position: 2, name: pageTitle, item: pageUrl },
27+
],
28+
};
29+
const itemListJsonLd = {
30+
'@context': 'https://schema.org',
31+
'@type': 'ItemList',
32+
name: `${category} tools on ${SITE_NAME}`,
33+
numberOfItems: categoryTools.length,
34+
itemListElement: categoryTools.map((t, i) => ({
35+
'@type': 'ListItem',
36+
position: i + 1,
37+
name: t.name,
38+
url: new URL(t.route, SITE_URL).href,
39+
})),
40+
};
41+
---
42+
43+
<Base
44+
title={`${pageTitle} — Free & Private`}
45+
description={description}
46+
keywords={[`${category.toLowerCase()} tools`, 'free', 'online', 'browser', 'privacy', 'no upload']}
47+
jsonLd={[breadcrumbJsonLd, itemListJsonLd]}
48+
>
49+
<main class="page-container py-8">
50+
<nav aria-label="Breadcrumb" class="mb-4">
51+
<ol class="flex items-center gap-1 text-sm font-bold uppercase tracking-wide text-muted-foreground">
52+
<li>
53+
<a href="/" class="border-2 border-border bg-muted px-2 py-1 text-foreground shadow-brutal-sm press-brutal">Home</a>
54+
</li>
55+
<li aria-hidden="true"><ChevronRight className="h-3.5 w-3.5" /></li>
56+
<li aria-current="page" class="text-foreground">{pageTitle}</li>
57+
</ol>
58+
</nav>
59+
60+
<header class="mb-8">
61+
<div class="mb-2 flex items-center gap-2">
62+
<span class={`inline-block h-5 w-5 border-2 border-border ${categoryColors[category]}`} />
63+
<h1 class="text-3xl font-bold uppercase tracking-tight">{pageTitle}</h1>
64+
<span class="text-sm font-bold text-muted-foreground">({categoryTools.length})</span>
65+
</div>
66+
<p class="max-w-3xl text-muted-foreground">{description}</p>
67+
</header>
68+
69+
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
70+
{categoryTools.map(tool => {
71+
const Icon = tool.icon;
72+
return (
73+
<a href={tool.route} class="group flex items-start gap-3 border-2 border-border bg-muted p-4 shadow-brutal press-brutal">
74+
<span class={`mt-0.5 border-2 border-border p-2 text-black ${categoryColors[tool.category]}`}>
75+
<Icon className="h-5 w-5" />
76+
</span>
77+
<span class="min-w-0">
78+
<span class="block font-bold">{tool.name}</span>
79+
<span class="block text-sm text-muted-foreground">{tool.summary}</span>
80+
</span>
81+
</a>
82+
);
83+
})}
84+
</div>
85+
</main>
86+
</Base>

src/pages/tools/[tool].astro

Lines changed: 102 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import Base from '@/layouts/Base.astro';
44
import ToolHost from '@/islands/ToolHost';
55
import ShareButton from '@/islands/share/ShareButton';
66
import { tools, getToolById } from '@/registry/tools';
7+
import { getToolSeo } from '@/registry/tool-seo';
8+
import { categoryColors, categorySlug } from '@/registry/categories';
79
import { SITE_URL, SITE_NAME } from '@/config';
810
911
export function getStaticPaths() {
@@ -16,14 +18,25 @@ export function getStaticPaths() {
1618
const { toolId } = Astro.props;
1719
const tool = getToolById(toolId)!;
1820
const toolUrl = new URL(tool.route, SITE_URL).href;
19-
// Sharper, keyword-aware description for search results.
20-
const metaDescription = `${tool.summary}. Free, on-device, and private — runs entirely in your browser with no uploads. Part of ${SITE_NAME}.`;
21+
const seo = getToolSeo(tool.id);
22+
23+
// Authored copy where present; otherwise fall back to the registry summary.
24+
const lead = seo?.intro ?? tool.summary;
25+
const metaDescription =
26+
seo?.description ??
27+
`${tool.summary}. Free, on-device, and private — runs entirely in your browser with no uploads. Part of ${SITE_NAME}.`;
28+
29+
// Category hub (breadcrumb + related links).
30+
const catPath = `/category/${categorySlug(tool.category)}`;
31+
const relatedTools = tools
32+
.filter(t => t.category === tool.category && t.id !== tool.id && !t.desktopOnly)
33+
.slice(0, 6);
2134
2235
const softwareJsonLd = {
2336
'@context': 'https://schema.org',
2437
'@type': 'SoftwareApplication',
2538
name: tool.name,
26-
description: tool.summary,
39+
description: metaDescription,
2740
applicationCategory: 'UtilitiesApplication',
2841
operatingSystem: 'Any (web browser)',
2942
url: toolUrl,
@@ -37,26 +50,47 @@ const breadcrumbJsonLd = {
3750
'@type': 'BreadcrumbList',
3851
itemListElement: [
3952
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL + '/' },
40-
{ '@type': 'ListItem', position: 2, name: tool.name, item: toolUrl },
53+
{ '@type': 'ListItem', position: 2, name: `${tool.category} Tools`, item: new URL(catPath, SITE_URL).href },
54+
{ '@type': 'ListItem', position: 3, name: tool.name, item: toolUrl },
4155
],
4256
};
57+
const jsonLd: Record<string, unknown>[] = [softwareJsonLd, breadcrumbJsonLd];
58+
if (seo?.howTo && seo.howTo.length > 0) {
59+
jsonLd.push({
60+
'@context': 'https://schema.org',
61+
'@type': 'HowTo',
62+
name: `How to use ${tool.name}`,
63+
step: seo.howTo.map((text, i) => ({ '@type': 'HowToStep', position: i + 1, text })),
64+
});
65+
}
66+
if (seo?.faqs && seo.faqs.length > 0) {
67+
jsonLd.push({
68+
'@context': 'https://schema.org',
69+
'@type': 'FAQPage',
70+
mainEntity: seo.faqs.map(f => ({
71+
'@type': 'Question',
72+
name: f.q,
73+
acceptedAnswer: { '@type': 'Answer', text: f.a },
74+
})),
75+
});
76+
}
4377
---
4478

4579
<Base
46-
title={tool.name}
80+
title={seo?.title ?? tool.name}
4781
description={metaDescription}
4882
keywords={tool.keywords}
49-
jsonLd={[softwareJsonLd, breadcrumbJsonLd]}
83+
jsonLd={jsonLd}
5084
>
5185
<main class="page-container py-8">
5286
<nav aria-label="Breadcrumb" class="mb-4">
53-
<ol class="flex items-center gap-1 text-sm font-bold uppercase tracking-wide text-muted-foreground">
87+
<ol class="flex flex-wrap items-center gap-1 text-sm font-bold uppercase tracking-wide text-muted-foreground">
88+
<li>
89+
<a href="/" class="border-2 border-border bg-muted px-2 py-1 text-foreground shadow-brutal-sm press-brutal">Home</a>
90+
</li>
91+
<li aria-hidden="true"><ChevronRight className="h-3.5 w-3.5" /></li>
5492
<li>
55-
<a
56-
href="/"
57-
class="border-2 border-border bg-muted px-2 py-1 text-foreground shadow-brutal-sm press-brutal"
58-
>Home</a
59-
>
93+
<a href={catPath} class="border-2 border-border bg-muted px-2 py-1 text-foreground shadow-brutal-sm press-brutal">{tool.category}</a>
6094
</li>
6195
<li aria-hidden="true"><ChevronRight className="h-3.5 w-3.5" /></li>
6296
<li aria-current="page" class="text-foreground">{tool.name}</li>
@@ -75,8 +109,63 @@ const breadcrumbJsonLd = {
75109
</div>
76110
<ShareButton client:idle url={toolUrl} title={`${tool.name} — ${SITE_NAME}`} text={tool.summary} />
77111
</div>
78-
<p class="text-muted-foreground">{tool.summary}</p>
112+
<p class="max-w-3xl text-muted-foreground">{lead}</p>
79113
</div>
114+
80115
<ToolHost client:load toolId={toolId} />
116+
117+
{(seo?.howTo?.length || seo?.faqs?.length || relatedTools.length > 0) && (
118+
<div class="mt-12 space-y-10 border-t-2 border-border pt-8">
119+
{seo?.howTo && seo.howTo.length > 0 && (
120+
<section aria-labelledby="howto-heading">
121+
<h2 id="howto-heading" class="mb-4 text-xl font-bold uppercase tracking-tight">How to use {tool.name}</h2>
122+
<ol class="max-w-3xl space-y-2">
123+
{seo.howTo.map((step, i) => (
124+
<li class="flex gap-3">
125+
<span class="flex h-6 w-6 shrink-0 items-center justify-center border-2 border-border bg-accent text-sm font-bold text-accent-foreground">{i + 1}</span>
126+
<span class="text-muted-foreground">{step}</span>
127+
</li>
128+
))}
129+
</ol>
130+
</section>
131+
)}
132+
133+
{seo?.faqs && seo.faqs.length > 0 && (
134+
<section aria-labelledby="faq-heading">
135+
<h2 id="faq-heading" class="mb-4 text-xl font-bold uppercase tracking-tight">Frequently asked questions</h2>
136+
<div class="max-w-3xl space-y-3">
137+
{seo.faqs.map(f => (
138+
<details class="border-2 border-border bg-muted p-4">
139+
<summary class="cursor-pointer font-bold">{f.q}</summary>
140+
<p class="mt-2 text-muted-foreground">{f.a}</p>
141+
</details>
142+
))}
143+
</div>
144+
</section>
145+
)}
146+
147+
{relatedTools.length > 0 && (
148+
<section aria-labelledby="related-heading">
149+
<h2 id="related-heading" class="mb-4 text-xl font-bold uppercase tracking-tight">Related {tool.category} tools</h2>
150+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
151+
{relatedTools.map(rt => {
152+
const Icon = rt.icon;
153+
return (
154+
<a href={rt.route} class="group flex items-start gap-3 border-2 border-border bg-muted p-4 shadow-brutal-sm press-brutal">
155+
<span class={`mt-0.5 border-2 border-border p-2 text-black ${categoryColors[rt.category]}`}>
156+
<Icon className="h-4 w-4" />
157+
</span>
158+
<span class="min-w-0">
159+
<span class="block font-bold">{rt.name}</span>
160+
<span class="block text-sm text-muted-foreground">{rt.summary}</span>
161+
</span>
162+
</a>
163+
);
164+
})}
165+
</div>
166+
</section>
167+
)}
168+
</div>
169+
)}
81170
</main>
82171
</Base>

src/registry/categories.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,21 @@ export const categoryColors: Record<Category, string> = {
3434
export const categoryNotes: Partial<Record<Category, string>> = {
3535
Network: 'These tools connect two devices directly (peer-to-peer). By default a minimal signaling server only introduces the devices — your media and files never pass through it — and you can switch to a fully serverless manual mode or bring your own STUN/TURN servers.',
3636
};
37+
38+
/** URL slug for a category hub page, e.g. 'PDF' → 'pdf'. */
39+
export function categorySlug(category: Category): string {
40+
return category.toLowerCase();
41+
}
42+
43+
/** SEO lead copy for each category hub page (unique, keyword-aware). */
44+
export const categoryDescriptions: Record<Category, string> = {
45+
Dev: 'Free developer utilities that run entirely in your browser — format and validate JSON, encode Base64 and URLs, hash and diff text, generate UUIDs, and more. Nothing is uploaded.',
46+
PDF: 'Work with PDFs privately in your browser — merge, split, compress, convert, repair, protect and edit. Your documents never leave your device, so even confidential files stay safe.',
47+
Image: 'Edit and convert images on your device — resize, crop, compress, convert formats, remove backgrounds, upscale, blur faces, extract text and more. No uploads, no watermarks.',
48+
Files: 'Everyday file utilities that keep your data local — archive, extract, encrypt and inspect files right in the browser with nothing sent to a server.',
49+
Draw: 'Simple drawing and diagramming tools that run in your browser — sketch, annotate and create diagrams without an account or any upload.',
50+
Media: 'Private audio and video utilities — convert, trim, record and transcribe media entirely on your device using on-device processing. Your recordings never leave your browser.',
51+
Network: 'Peer-to-peer tools that connect two devices directly to transfer files or communicate — your data flows device to device, not through a server.',
52+
Maps: 'Open-source mapping tools — convert coordinates, explore and export maps, and view GeoJSON, GPX and KML files. Built on open map data, running in your browser.',
53+
Playground: 'Interactive playgrounds and experiments to explore and learn — all running client-side in your browser.',
54+
};

0 commit comments

Comments
 (0)