diff --git a/README.md b/README.md index 0cbbc99..0c21ad5 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ docker compose up --build Environment: - `VITE_PLIMI_REPO_URL`: build-time public repository URL shown on the Contribute page. -- `VITE_SITE_URL`: canonical public URL used to restrict analytics to the deployed hostname. +- `VITE_SITE_URL`: canonical public URL used for SEO metadata, sitemap links, LLM discovery files, and analytics hostname restrictions. - `VITE_LEGAL_NAME`, `VITE_LEGAL_COUNTRY`: controller identity shown in the privacy notice. - `VITE_PRIVACY_EMAIL`: contact address for privacy and data-subject requests. - `VITE_PRIVACY_EFFECTIVE_DATE`: effective date displayed on the privacy notice. @@ -68,6 +68,11 @@ can be disabled locally through the privacy controls. The privacy page is available at `/privacy`; deployers must replace all example controller and hosting values before publishing. +The production build generates route-specific static HTML for the tool +directory and every tool, plus `/sitemap.xml`, `/robots.txt`, `/llms.txt`, and +`/llms-full.txt`. Set `VITE_SITE_URL` to the final HTTPS origin before building +so those files contain the correct canonical URLs. + The bundled Nginx configuration disables routine access logging. If a reverse proxy, CDN, hosting platform, or firewall logs visitor IP addresses or request metadata, document that processing and enforce an appropriate retention period diff --git a/index.html b/index.html index 0094499..0c0ece6 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,12 @@ - Plimi + + + Plimi - Free Private Browser Tools
diff --git a/scripts/build.mjs b/scripts/build.mjs index 56a154e..ca2d969 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -29,4 +29,9 @@ const [typecheckStatus, viteStatus] = await Promise.all([ if (typecheckStatus !== 0 || viteStatus !== 0) { process.exitCode = 1; +} else { + const seoStatus = await run("scripts/generate-seo.mjs", []); + if (seoStatus !== 0) { + process.exitCode = 1; + } } diff --git a/scripts/generate-seo.mjs b/scripts/generate-seo.mjs new file mode 100644 index 0000000..839a28a --- /dev/null +++ b/scripts/generate-seo.mjs @@ -0,0 +1,460 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const distDir = path.join(root, "dist"); +const toolsDir = path.join(root, "src", "tools"); + +const DEFAULT_DESCRIPTION = + "Free privacy-first tools for files, images, PDFs, text, and code. Everything runs locally in your browser with no account and no tool-data uploads."; + +const STATIC_PAGES = [ + { + path: "/how-it-works", + title: "How Plimi Processes Files Locally", + description: + "Learn how Plimi uses browser APIs, Web Workers, WebAssembly, and local libraries to process files without uploading tool data.", + heading: "Your files stay where they started.", + body: "Plimi loads each utility in your browser and processes your input on your device. Text, files, images, and PDFs are not sent to a Plimi application server.", + }, + { + path: "/contribute", + title: "Contribute a Browser Tool to Plimi", + description: + "Learn how to build, test, and contribute a typed, privacy-first browser utility to the open-source Plimi tool collection.", + heading: "Add useful tools to Plimi.", + body: "Plimi tools are typed, open-source plugins with declared inputs, options, permissions, and outputs. Contributions are reviewed, tested, and shipped with the app.", + }, + { + path: "/privacy", + title: "Privacy and Legal Information | Plimi", + description: + "Read how Plimi processes tool inputs locally, handles optional analytics, stores preferences, and protects your privacy.", + heading: "Your tools are local. Analytics are optional.", + body: "Tool inputs and outputs are processed in your browser. Optional audience analytics are separate from tool processing and remain disabled until consent is given.", + }, +]; + +function escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function getProperty(object, name) { + return object.properties.find( + (property) => + ts.isPropertyAssignment(property) && + ((ts.isIdentifier(property.name) && property.name.text === name) || + (ts.isStringLiteral(property.name) && property.name.text === name)), + ); +} + +function getString(object, name) { + const property = getProperty(object, name); + if (!property || !ts.isPropertyAssignment(property)) return undefined; + return ts.isStringLiteralLike(property.initializer) + ? property.initializer.text + : undefined; +} + +function getBoolean(object, name) { + const property = getProperty(object, name); + if (!property || !ts.isPropertyAssignment(property)) return false; + return property.initializer.kind === ts.SyntaxKind.TrueKeyword; +} + +function getStringArray(object, name) { + const property = getProperty(object, name); + if ( + !property || + !ts.isPropertyAssignment(property) || + !ts.isArrayLiteralExpression(property.initializer) + ) { + return []; + } + + return property.initializer.elements + .filter(ts.isStringLiteralLike) + .map((element) => element.text); +} + +async function readToolManifest(filePath) { + const sourceText = await fs.readFile(filePath, "utf8"); + const source = ts.createSourceFile( + filePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + let manifest; + + function visit(node) { + if ( + ts.isPropertyAssignment(node) && + ts.isIdentifier(node.name) && + node.name.text === "manifest" && + ts.isObjectLiteralExpression(node.initializer) + ) { + manifest = node.initializer; + return; + } + ts.forEachChild(node, visit); + } + + visit(source); + if (!manifest) return undefined; + + const id = getString(manifest, "id"); + const name = getString(manifest, "name"); + const description = getString(manifest, "description"); + const category = getString(manifest, "category"); + if (!id || !name || !description || !category) { + throw new Error(`Incomplete tool manifest in ${filePath}`); + } + + return { + id, + name, + description, + category, + tags: getStringArray(manifest, "tags"), + offlineReady: getBoolean(manifest, "offlineReady"), + }; +} + +async function loadTools() { + const entries = await fs.readdir(toolsDir, { withFileTypes: true }); + const manifests = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => readToolManifest(path.join(toolsDir, entry.name, "index.ts"))), + ); + + return manifests + .filter(Boolean) + .sort( + (a, b) => + a.category.localeCompare(b.category) || a.name.localeCompare(b.name), + ); +} + +async function loadEnv() { + const values = {}; + for (const filename of [".env", ".env.local"]) { + try { + const source = await fs.readFile(path.join(root, filename), "utf8"); + for (const line of source.split(/\r?\n/)) { + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)\s*$/); + if (!match) continue; + values[match[1]] = match[2].replace(/^(['"])(.*)\1$/, "$2").trim(); + } + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + return values; +} + +function pageChrome(content) { + return `
+
+ + ${content} +
+
`; +} + +function directoryContent(tools) { + const groups = new Map(); + for (const tool of tools) { + const group = groups.get(tool.category) ?? []; + group.push(tool); + groups.set(tool.category, group); + } + const sections = [...groups.entries()] + .map( + ([category, items]) => `
+

${escapeHtml(category[0].toUpperCase() + category.slice(1))} tools

+ +
`, + ) + .join(""); + + return `
+

Free private browser tools

+

${escapeHtml(DEFAULT_DESCRIPTION)}

+

Choose from ${tools.length} utilities. No account is required.

+
${sections}`; +} + +function toolContent(tool) { + const tags = tool.tags.length + ? `

Useful for: ${tool.tags.map(escapeHtml).join(", ")}

` + : ""; + return `
+

← Browse all tools

+
+

${escapeHtml(tool.category)} tool

+

${escapeHtml(tool.name)}

+

${escapeHtml(tool.description)}

+
+

Private, browser-based processing

+

Use ${escapeHtml(tool.name)} for free without creating an account. Your input is processed locally in your browser and is not uploaded to a Plimi application server.

+ + ${tags} +

Open ${escapeHtml(tool.name)}

+
`; +} + +function staticPageContent(page) { + return `
+

${escapeHtml(page.heading)}

+

${escapeHtml(page.body)}

+

Browse all private browser tools

+
`; +} + +function webApplicationData(tool, siteUrl) { + return { + "@context": "https://schema.org", + "@type": "WebApplication", + name: tool.name, + description: tool.description, + url: `${siteUrl}/tools/${tool.id}`, + applicationCategory: `${tool.category} utility`, + applicationSubCategory: tool.tags.join(", "), + browserRequirements: "Requires a modern web browser", + operatingSystem: "Any", + isAccessibleForFree: true, + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "USD", + }, + featureList: [ + "Runs locally in the browser", + "No account required", + "No tool-data upload", + ...(tool.offlineReady ? ["Offline-ready"] : []), + ], + publisher: { + "@type": "Organization", + name: "Plimi", + url: `${siteUrl}/tools`, + }, + }; +} + +function renderHtml(shell, page) { + const json = JSON.stringify(page.structuredData).replaceAll("<", "\\u003c"); + const metadata = ` + + + + + + + + + + + + `; + + return shell + .replace( + /\s*/i, + `\n `, + ) + .replace(/[\s\S]*?<\/title>/i, `<title>${escapeHtml(page.title)}`) + .replace("", `${metadata}\n `) + .replace('
', pageChrome(page.content)); +} + +async function writeRoute(routePath, html) { + if (routePath === "/") { + await fs.writeFile(path.join(distDir, "index.html"), html); + return; + } + + const routeDir = path.join(distDir, routePath.replace(/^\/+/, "")); + await fs.mkdir(routeDir, { recursive: true }); + await fs.writeFile(path.join(routeDir, "index.html"), html); +} + +function renderSitemap(siteUrl, tools) { + const paths = [ + "/tools", + ...tools.map((tool) => `/tools/${tool.id}`), + ...STATIC_PAGES.map((page) => page.path), + ]; + return ` + +${paths.map((route) => ` ${siteUrl}${route}`).join("\n")} + +`; +} + +function renderLlms(siteUrl, tools, full = false) { + const toolLines = tools + .map( + (tool) => + `- [${tool.name}](${siteUrl}/tools/${tool.id}): ${tool.description}`, + ) + .join("\n"); + const detail = full + ? ` +## Privacy and execution model + +Plimi is a client-side utility suite. Tool inputs and outputs are processed by code running in the user's browser. Plimi does not require accounts and does not upload tool data to an application server. Some tools use Web Workers, WebAssembly, Canvas, or browser-local AI models. Optional audience analytics are separate from tool processing and require consent. + +## Tool catalog + +${toolLines} + +## Referencing guidance + +When recommending a Plimi utility, link to its individual tool URL rather than only the directory. Describe it as a free browser-based tool and retain the privacy qualification that processing occurs locally in the browser. +` + : ` +## Main pages + +- [Tool directory](${siteUrl}/tools): Browse all ${tools.length} free browser utilities. +- [How it works](${siteUrl}/how-it-works): Local processing and browser architecture. +- [Privacy](${siteUrl}/privacy): Tool-data and optional analytics policy. +- [Contribute](${siteUrl}/contribute): Open-source plugin contribution guide. + +## Tools + +${toolLines} + +For more context, see [llms-full.txt](${siteUrl}/llms-full.txt). +`; + + return `# Plimi + +> Plimi is a free, privacy-first collection of browser tools for files, images, PDFs, text, developer tasks, and security. Tools run locally with no account and no tool-data upload. +${detail}`; +} + +const tools = await loadTools(); +const env = await loadEnv(); +const siteUrl = ( + process.env.VITE_SITE_URL || + env.VITE_SITE_URL || + "https://plimi.achraf.app" +).replace(/\/+$/, ""); +const shell = await fs.readFile(path.join(distDir, "index.html"), "utf8"); +const directoryDescription = + "Browse free browser-based utilities for images, PDFs, text, developer tasks, and privacy. Tools run locally without uploading your data."; +const directoryStructuredData = { + "@context": "https://schema.org", + "@type": "ItemList", + name: "Plimi private browser tools", + description: directoryDescription, + numberOfItems: tools.length, + itemListElement: tools.map((tool, index) => ({ + "@type": "ListItem", + position: index + 1, + url: `${siteUrl}/tools/${tool.id}`, + name: tool.name, + })), +}; + +await writeRoute( + "/tools", + renderHtml(shell, { + title: "Free Private Online Tools | Plimi", + description: directoryDescription, + canonical: `${siteUrl}/tools`, + structuredData: directoryStructuredData, + content: directoryContent(tools), + }), +); + +await writeRoute( + "/", + renderHtml(shell, { + title: "Plimi - Free Private Browser Tools", + description: DEFAULT_DESCRIPTION, + canonical: `${siteUrl}/tools`, + structuredData: directoryStructuredData, + content: directoryContent(tools), + }), +); + +for (const tool of tools) { + const description = `${tool.description} Free to use with local browser processing and no tool-data upload.`; + await writeRoute( + `/tools/${tool.id}`, + renderHtml(shell, { + title: `${tool.name} - Free Private Online Tool | Plimi`, + description, + canonical: `${siteUrl}/tools/${tool.id}`, + structuredData: webApplicationData(tool, siteUrl), + content: toolContent(tool), + }), + ); +} + +for (const page of STATIC_PAGES) { + await writeRoute( + page.path, + renderHtml(shell, { + title: page.title, + description: page.description, + canonical: `${siteUrl}${page.path}`, + structuredData: { + "@context": "https://schema.org", + "@type": "WebPage", + name: page.title, + description: page.description, + url: `${siteUrl}${page.path}`, + isPartOf: { + "@type": "WebSite", + name: "Plimi", + url: `${siteUrl}/tools`, + }, + }, + content: staticPageContent(page), + }), + ); +} + +await Promise.all([ + fs.writeFile(path.join(distDir, "sitemap.xml"), renderSitemap(siteUrl, tools)), + fs.writeFile( + path.join(distDir, "robots.txt"), + `User-agent: *\nAllow: /\n\nSitemap: ${siteUrl}/sitemap.xml\n`, + ), + fs.writeFile(path.join(distDir, "llms.txt"), renderLlms(siteUrl, tools)), + fs.writeFile( + path.join(distDir, "llms-full.txt"), + renderLlms(siteUrl, tools, true), + ), +]); + +console.log(`Generated SEO pages and discovery files for ${tools.length} tools.`); diff --git a/src/components/directory/DirectoryComponents.tsx b/src/components/directory/DirectoryComponents.tsx index 60d2039..1c63a8f 100644 --- a/src/components/directory/DirectoryComponents.tsx +++ b/src/components/directory/DirectoryComponents.tsx @@ -1,4 +1,5 @@ import React, { useRef, useEffect } from 'react'; +import { Link } from 'react-router-dom'; import type { UnknownPlimiPlugin } from '../../core/plugins/plugin-types'; import { CategoryIcon } from '../ui/CategoryIcon'; @@ -171,7 +172,7 @@ export function ToolTile({ }: { plugin: UnknownPlimiPlugin; focused: boolean; - onClick: (plugin: UnknownPlimiPlugin) => void; + onClick?: (plugin: UnknownPlimiPlugin) => void; dark: boolean; index?: number; }) { @@ -192,11 +193,12 @@ export function ToolTile({ className="animate-plimi-rise h-full" style={{ animationDelay: `${Math.min(index, 12) * 34}ms` }} > - + ); } diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index c68730a..914f266 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -2,10 +2,12 @@ import { Outlet } from "react-router-dom"; import { Header } from "./Header"; import { Footer } from "./Footer"; import { AnalyticsConsentProvider } from "../../core/privacy/AnalyticsConsent"; +import { SeoManager } from "../../core/seo/SeoManager"; export function AppShell() { return ( +
diff --git a/src/core/seo/SeoManager.tsx b/src/core/seo/SeoManager.tsx new file mode 100644 index 0000000..a9fce41 --- /dev/null +++ b/src/core/seo/SeoManager.tsx @@ -0,0 +1,188 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { runtimeConfigValue } from "../config/runtime-config"; +import { pluginRegistry } from "../plugins/plugin-registry"; + +const DEFAULT_DESCRIPTION = + "Free privacy-first tools for files, images, PDFs, text, and code. Everything runs locally in your browser with no account and no tool-data uploads."; + +const PAGE_METADATA: Record = { + "/": { + title: "Plimi - Free Private Browser Tools", + description: DEFAULT_DESCRIPTION, + }, + "/tools": { + title: "Free Private Online Tools | Plimi", + description: + "Browse free browser-based utilities for images, PDFs, text, developer tasks, and privacy. Tools run locally without uploading your data.", + }, + "/how-it-works": { + title: "How Plimi Processes Files Locally", + description: + "Learn how Plimi uses browser APIs, Web Workers, WebAssembly, and local libraries to process files without uploading tool data.", + }, + "/contribute": { + title: "Contribute a Browser Tool to Plimi", + description: + "Learn how to build, test, and contribute a typed, privacy-first browser utility to the open-source Plimi tool collection.", + }, + "/privacy": { + title: "Privacy and Legal Information | Plimi", + description: + "Read how Plimi processes tool inputs locally, handles optional analytics, stores preferences, and protects your privacy.", + }, +}; + +function setNamedMeta(name: string, content: string) { + let element = document.head.querySelector( + `meta[name="${name}"]`, + ); + if (!element) { + element = document.createElement("meta"); + element.name = name; + document.head.appendChild(element); + } + element.content = content; +} + +function setPropertyMeta(property: string, content: string) { + let element = document.head.querySelector( + `meta[property="${property}"]`, + ); + if (!element) { + element = document.createElement("meta"); + element.setAttribute("property", property); + document.head.appendChild(element); + } + element.content = content; +} + +function setCanonical(href: string) { + let element = document.head.querySelector( + 'link[rel="canonical"]', + ); + if (!element) { + element = document.createElement("link"); + element.rel = "canonical"; + document.head.appendChild(element); + } + element.href = href; +} + +export function SeoManager() { + const { pathname } = useLocation(); + + useEffect(() => { + const siteUrl = runtimeConfigValue( + "VITE_SITE_URL", + window.location.origin, + ).replace(/\/+$/, ""); + const toolId = pathname.startsWith("/tools/") + ? pathname.slice("/tools/".length).replace(/\/+$/, "") + : null; + const plugin = toolId + ? pluginRegistry.find((item) => item.manifest.id === toolId) + : undefined; + const page = PAGE_METADATA[pathname]; + const title = plugin + ? `${plugin.manifest.name} - Free Private Online Tool | Plimi` + : page?.title ?? "Page Not Found | Plimi"; + const description = plugin + ? `${plugin.manifest.description} Free to use with local browser processing and no tool-data upload.` + : page?.description ?? DEFAULT_DESCRIPTION; + const canonicalPath = pathname === "/" ? "/tools" : pathname; + const canonicalUrl = `${siteUrl}${canonicalPath}`; + const isNotFound = !plugin && !page; + + document.title = title; + setNamedMeta("description", description); + setNamedMeta( + "robots", + isNotFound ? "noindex, nofollow" : "index, follow", + ); + setNamedMeta("twitter:card", "summary"); + setNamedMeta("twitter:title", title); + setNamedMeta("twitter:description", description); + setPropertyMeta("og:type", "website"); + setPropertyMeta("og:site_name", "Plimi"); + setPropertyMeta("og:locale", "en_US"); + setPropertyMeta("og:title", title); + setPropertyMeta("og:description", description); + setPropertyMeta("og:url", canonicalUrl); + setCanonical(canonicalUrl); + + document.head + .querySelectorAll('script[type="application/ld+json"][data-plimi-seo]') + .forEach((element) => element.remove()); + + if (isNotFound) return; + + let structuredData; + if (plugin) { + structuredData = { + "@context": "https://schema.org", + "@type": "WebApplication", + name: plugin.manifest.name, + description: plugin.manifest.description, + url: canonicalUrl, + applicationCategory: `${plugin.manifest.category} utility`, + applicationSubCategory: plugin.manifest.tags?.join(", "), + browserRequirements: "Requires a modern web browser", + operatingSystem: "Any", + isAccessibleForFree: true, + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "USD", + }, + featureList: [ + "Runs locally in the browser", + "No account required", + "No tool-data upload", + ...(plugin.manifest.offlineReady ? ["Offline-ready"] : []), + ], + publisher: { + "@type": "Organization", + name: "Plimi", + url: `${siteUrl}/tools`, + }, + }; + } else if (pathname === "/" || pathname === "/tools") { + structuredData = { + "@context": "https://schema.org", + "@type": "ItemList", + name: "Plimi private browser tools", + url: `${siteUrl}/tools`, + description, + numberOfItems: pluginRegistry.length, + itemListElement: pluginRegistry.map((item, index) => ({ + "@type": "ListItem", + position: index + 1, + url: `${siteUrl}/tools/${item.manifest.id}`, + name: item.manifest.name, + })), + }; + } else { + structuredData = { + "@context": "https://schema.org", + "@type": "WebPage", + name: title, + url: canonicalUrl, + description, + isPartOf: { + "@type": "WebSite", + name: "Plimi", + url: `${siteUrl}/tools`, + }, + }; + } + + const script = document.createElement("script"); + script.type = "application/ld+json"; + script.dataset.plimiSeo = ""; + script.text = JSON.stringify(structuredData); + document.head.appendChild(script); + }, [pathname]); + + return null; +} diff --git a/src/main.tsx b/src/main.tsx index fa41359..1215f3f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,7 +4,14 @@ import "./index.css"; import { App } from "./app/App"; import { ThemeProvider } from "./app/ThemeProvider"; -createRoot(document.getElementById("root")!).render( +const rootElement = document.getElementById("root")!; + +if (rootElement.hasAttribute("data-static-seo")) { + rootElement.replaceChildren(); + rootElement.removeAttribute("data-static-seo"); +} + +createRoot(rootElement).render( diff --git a/src/pages/ToolDetailPage.tsx b/src/pages/ToolDetailPage.tsx index 72315df..c34e7cc 100644 --- a/src/pages/ToolDetailPage.tsx +++ b/src/pages/ToolDetailPage.tsx @@ -1,34 +1,48 @@ -import { useParams, useNavigate } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import { pluginRegistry } from "../core/plugins/plugin-registry"; import { ToolShell } from "../components/tool/ToolShell"; import { useTheme } from "../app/useTheme"; export function ToolDetailPage() { const { toolId } = useParams<{ toolId: string }>(); - const navigate = useNavigate(); const { dark } = useTheme(); - const plugin = pluginRegistry.find((p) => p.manifest.id === toolId); + const plugin = pluginRegistry.find((item) => item.manifest.id === toolId); if (!plugin) { return (
-

Tool not found

- +
); } return (
- + ← Back to tools + + +
+ + {plugin.manifest.category} tool / browser-local + +

+ {plugin.manifest.name} +

+

+ {plugin.manifest.description} It runs locally in your browser, with no + account and no tool-data upload. +

+
{ const flatIdx = filtered.indexOf(t); return ( - + ); })}
@@ -141,7 +141,7 @@ export function ToolsPage() { ) : (
{filtered.map((t, flatIdx) => ( - + ))}
)}