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]) => `
+
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.