+
+
+ {/* Native devices get the main button; a caret still exposes explicit channels. */}
+ {native && (
+
+ )}
+
+ {open && (
+
+ {native && (
+
+ )}
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 70681ad..a1bd6ba 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,8 +1,9 @@
---
import Base from '@/layouts/Base.astro';
import { ToolGrid } from '@/components/ToolGrid';
+import ShareButton from '@/islands/share/ShareButton';
import { tools } from '@/registry/tools';
-import { SITE_URL, SITE_NAME } from '@/config';
+import { SITE_URL, SITE_NAME, SITE_TAGLINE } from '@/config';
const homeTitle = `${SITE_NAME} — Free Privacy-First Browser Tools`;
const homeDescription = `${tools.length} free online tools that run entirely in your browser — PDF, image, file, developer, and media utilities. No uploads, no sign-up, works offline. Your files never leave your device.`;
@@ -51,6 +52,9 @@ const toolList = {
-
-
{tool.name}
- {tool.status !== 'stable' && (
-
- {tool.status}
-
- )}
+
+
+
{tool.name}
+ {tool.status !== 'stable' && (
+
+ {tool.status}
+
+ )}
+
+
{tool.summary}
diff --git a/src/tools/share/share.lib.test.ts b/src/tools/share/share.lib.test.ts
new file mode 100644
index 0000000..91794db
--- /dev/null
+++ b/src/tools/share/share.lib.test.ts
@@ -0,0 +1,61 @@
+import { describe, it, expect } from 'vitest';
+import { shareIntentUrl, canNativeShare, SHARE_CHANNELS } from './share.lib';
+
+const target = { url: 'https://goodwebtools.com/tools/json-format', title: 'JSON Formatter', text: 'Format & validate JSON' };
+const encUrl = encodeURIComponent(target.url);
+const encText = encodeURIComponent(target.text);
+
+describe('shareIntentUrl', () => {
+ it('builds an X (Twitter) intent with url + text', () => {
+ expect(shareIntentUrl('x', target)).toBe(`https://x.com/intent/tweet?url=${encUrl}&text=${encText}`);
+ });
+
+ it('builds a Facebook sharer with the url', () => {
+ expect(shareIntentUrl('facebook', target)).toBe(`https://www.facebook.com/sharer/sharer.php?u=${encUrl}`);
+ });
+
+ it('builds a WhatsApp intent joining text and url', () => {
+ expect(shareIntentUrl('whatsapp', target)).toBe(`https://api.whatsapp.com/send?text=${encText}%20${encUrl}`);
+ });
+
+ it('builds a Telegram share url', () => {
+ expect(shareIntentUrl('telegram', target)).toBe(`https://t.me/share/url?url=${encUrl}&text=${encText}`);
+ });
+
+ it('builds a mailto with subject and body', () => {
+ expect(shareIntentUrl('email', target)).toBe(
+ `mailto:?subject=${encodeURIComponent(target.title)}&body=${encText}%20${encUrl}`,
+ );
+ });
+
+ it('falls back to the title when no text is given', () => {
+ const t = { url: 'https://x.test/', title: 'Hi there' };
+ expect(shareIntentUrl('x', t)).toContain(`text=${encodeURIComponent('Hi there')}`);
+ });
+
+ it('percent-encodes special characters so the URL is safe', () => {
+ const t = { url: 'https://x.test/?a=1&b=2', title: 'A & B?' };
+ const out = shareIntentUrl('telegram', t);
+ expect(out).toContain(encodeURIComponent('https://x.test/?a=1&b=2'));
+ expect(out).not.toContain('A & B?');
+ });
+});
+
+describe('canNativeShare', () => {
+ it('is true when navigator.share is a function', () => {
+ expect(canNativeShare({ share: () => Promise.resolve() } as unknown as Navigator)).toBe(true);
+ });
+ it('is false when share is absent', () => {
+ expect(canNativeShare({} as Navigator)).toBe(false);
+ });
+ it('is false when navigator is undefined (SSR)', () => {
+ expect(canNativeShare(undefined)).toBe(false);
+ });
+});
+
+describe('SHARE_CHANNELS', () => {
+ it('includes the requested channels and copy', () => {
+ const ids = SHARE_CHANNELS.map(c => c.id);
+ expect(ids).toEqual(['x', 'facebook', 'whatsapp', 'telegram', 'email', 'copy']);
+ });
+});
diff --git a/src/tools/share/share.lib.ts b/src/tools/share/share.lib.ts
new file mode 100644
index 0000000..f3318a2
--- /dev/null
+++ b/src/tools/share/share.lib.ts
@@ -0,0 +1,50 @@
+/**
+ * Share-intent helpers for the Share button. Pure and framework-free so the
+ * island stays thin. No third-party SDKs — every channel is a plain intent URL
+ * (nothing is loaded from Meta/X/etc.), matching the site's privacy stance.
+ */
+
+export type ShareChannel = 'x' | 'facebook' | 'whatsapp' | 'telegram' | 'email' | 'copy';
+
+export interface ShareTarget {
+ /** Canonical URL being shared. */
+ url: string;
+ /** Human title (page/tool name). */
+ title: string;
+ /** Optional longer blurb; falls back to the title. */
+ text?: string;
+}
+
+/** Channels shown in the UI, in order. `copy` is handled specially (no intent URL). */
+export const SHARE_CHANNELS: { id: ShareChannel; label: string }[] = [
+ { id: 'x', label: 'X' },
+ { id: 'facebook', label: 'Facebook' },
+ { id: 'whatsapp', label: 'WhatsApp' },
+ { id: 'telegram', label: 'Telegram' },
+ { id: 'email', label: 'Email' },
+ { id: 'copy', label: 'Copy link' },
+];
+
+/** The share-intent URL for a channel. `copy` has none — copy the URL directly instead. */
+export function shareIntentUrl(channel: Exclude
, target: ShareTarget): string {
+ const url = encodeURIComponent(target.url);
+ const title = encodeURIComponent(target.title);
+ const text = encodeURIComponent(target.text ?? target.title);
+ switch (channel) {
+ case 'x':
+ return `https://x.com/intent/tweet?url=${url}&text=${text}`;
+ case 'facebook':
+ return `https://www.facebook.com/sharer/sharer.php?u=${url}`;
+ case 'whatsapp':
+ return `https://api.whatsapp.com/send?text=${text}%20${url}`;
+ case 'telegram':
+ return `https://t.me/share/url?url=${url}&text=${text}`;
+ case 'email':
+ return `mailto:?subject=${title}&body=${text}%20${url}`;
+ }
+}
+
+/** Whether the browser exposes the native share sheet (mobile, some desktop). */
+export function canNativeShare(nav: Navigator | undefined = typeof navigator !== 'undefined' ? navigator : undefined): boolean {
+ return !!nav && typeof nav.share === 'function';
+}