+
+
+
diff --git a/docs/public/robots.txt b/docs/public/robots.txt
new file mode 100644
index 00000000..5147ec8c
--- /dev/null
+++ b/docs/public/robots.txt
@@ -0,0 +1,19 @@
+User-agent: *
+Allow: /
+
+User-agent: GPTBot
+Allow: /
+
+User-agent: Claude-Web
+Allow: /
+
+User-agent: Anthropic-AI
+Allow: /
+
+User-agent: ChatGPT-User
+Allow: /
+
+User-agent: Google-Extended
+Allow: /
+
+Sitemap: https://docs.zerodev.app/sitemap.xml
diff --git a/package.json b/package.json
index c29f06e0..53a53230 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"type": "module",
"scripts": {
"dev": "vocs dev",
- "build": "vocs build",
+ "build": "vocs build && node scripts/sitemap.mjs",
"start": "node server.mjs",
"preview": "vocs preview",
"check:redirects": "node scripts/check-redirects.mjs",
diff --git a/scripts/check-redirects.mjs b/scripts/check-redirects.mjs
index 0334ceb9..d3875502 100644
--- a/scripts/check-redirects.mjs
+++ b/scripts/check-redirects.mjs
@@ -84,6 +84,18 @@ for (const { from, to } of redirects) {
}
}
+// The v5.3.x canonical tags are derived from this table: /sdk/v5_3_x/x credits
+// whatever /sdk/x redirects to. A page that stops mapping falls back to
+// crediting itself, and quietly competes with its current equivalent again.
+// The set's index page has no equivalent and is excluded on purpose.
+for (const route of ROUTES) {
+ if (!route.startsWith("/sdk/v5_3_x/")) continue;
+ const current = resolveRedirect(route.replace("/sdk/v5_3_x", "/sdk"));
+ if (!current?.startsWith("/")) {
+ errors.push(`${route} has no current page to credit, so its canonical falls back to itself`);
+ }
+}
+
for (const [prefix, target] of PREFIX) {
if (!ROUTES.has(stripTrailingSlash(target))) {
errors.push(`prefix ${prefix} -> ${target} (no such page)`);
diff --git a/scripts/sitemap.mjs b/scripts/sitemap.mjs
new file mode 100644
index 00000000..d70aec13
--- /dev/null
+++ b/scripts/sitemap.mjs
@@ -0,0 +1,99 @@
+// Writes docs/dist/sitemap.xml after `vocs build`.
+//
+// The list is derived from the built HTML rather than from the page sources, so
+// the sitemap can never disagree with what the pages themselves declare. A page
+// is listed only when it asks to be indexed: no noindex, and a canonical tag
+// pointing at itself. Everything else is a duplicate or a dead end, and Google
+// treats a sitemap entry that contradicts the page as an error.
+
+import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { resolveRedirect, stripTrailingSlash } from "../resolve-redirect.js";
+
+export const ORIGIN = "https://docs.zerodev.app";
+
+const ROOT = fileURLToPath(new URL("..", import.meta.url));
+const DIST = join(ROOT, "docs", "dist");
+
+// Under this many characters of visible text a page has nothing to rank for.
+const MIN_TEXT = 50;
+
+function pages(dir, prefix = "") {
+ const out = [];
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry);
+ if (statSync(full).isDirectory()) out.push(...pages(full, `${prefix}/${entry}`));
+ else if (entry === "index.html") out.push([prefix || "/", full]);
+ }
+ return out;
+}
+
+const textOf = (html) =>
+ html
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, "")
+ .replace(/<[^>]+>/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+
+const tag = (html, re) => html.match(re)?.[1] ?? null;
+
+export function sitemapEntries() {
+ const skipped = { redirected: 0, noindex: 0, duplicate: 0, empty: 0 };
+ const urls = [];
+
+ for (const [route, file] of pages(DIST)) {
+ // .vocs holds the search index, not pages.
+ if (route.startsWith("/.")) continue;
+
+ if (resolveRedirect(route)) {
+ skipped.redirected++;
+ continue;
+ }
+
+ const html = readFileSync(file, "utf8");
+
+ if (/]+name=["']robots["'][^>]+noindex/i.test(html)) {
+ skipped.noindex++;
+ continue;
+ }
+
+ const canonical = tag(html, /]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
+ if (canonical && stripTrailingSlash(canonical) !== stripTrailingSlash(ORIGIN + route)) {
+ skipped.duplicate++;
+ continue;
+ }
+
+ if (textOf(html).length < MIN_TEXT) {
+ skipped.empty++;
+ continue;
+ }
+
+ urls.push(route === "/" ? `${ORIGIN}/` : ORIGIN + route);
+ }
+
+ return { urls: urls.sort(), skipped };
+}
+
+function main() {
+ const { urls, skipped } = sitemapEntries();
+
+ const xml = [
+ '',
+ '',
+ ...urls.map((u) => ` ${u}`),
+ "",
+ "",
+ ].join("\n");
+
+ writeFileSync(join(DIST, "sitemap.xml"), xml);
+
+ const dropped = Object.entries(skipped)
+ .filter(([, n]) => n)
+ .map(([k, n]) => `${n} ${k}`)
+ .join(", ");
+ console.log(`sitemap.xml: ${urls.length} urls${dropped ? ` (skipped ${dropped})` : ""}`);
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) main();
diff --git a/vocs.config.tsx b/vocs.config.tsx
index 7c0bc7e5..8ed5f3b0 100644
--- a/vocs.config.tsx
+++ b/vocs.config.tsx
@@ -12,6 +12,34 @@ const componentsDir = resolve(process.cwd(), "docs/components");
dotenv.config();
+// Every page declares one address, and the host is always docs.zerodev.app.
+// The same content is also served on new-docs.zerodev.app; an absolute
+// canonical makes that copy credit this site instead of competing with it. A
+// path-only canonical would resolve against whichever host served the page,
+// which is no fix at all.
+const ORIGIN = "https://docs.zerodev.app";
+
+// The v5.3.x set stays live for readers still on that SDK, but each page
+// credits its current equivalent so the two versions stop competing.
+//
+// The mapping is derived, not hand-written: /sdk/v5_3_x/x is the old version of
+// /sdk/x, and every /sdk/x already has a target in the redirect table that was
+// checked page by page. Deriving it means the two can never drift apart.
+// check:redirects fails if a page stops mapping.
+function canonicalPath(path: string) {
+ if (!path.startsWith("/sdk/v5_3_x")) return path;
+ const current = resolveRedirect(path.replace("/sdk/v5_3_x", "/sdk"));
+ // An off-site target cannot be a canonical, and the set's index page has no
+ // equivalent. Both fall back to crediting themselves.
+ return current?.startsWith("/") ? current : path;
+}
+
+// @zerodev/waas has had no release in about two years. The pages stay so old
+// links still resolve, but they must not compete in search or teach an
+// interface with no future.
+const isUnmaintained = (path: string) =>
+ path.startsWith("/advanced/react-hooks/");
+
export default defineConfig({
iconUrl: "/favicon.ico",
logoUrl: {
@@ -85,20 +113,29 @@ export default defineConfig({
title: "ZeroDev",
titleTemplate: "%s – ZeroDev",
description: "The most powerful smart account.",
- head: (
- <>
-
-
-
-
- >
- ),
+ head({ path }) {
+ const canonical = ORIGIN + canonicalPath(path);
+ return (
+ <>
+
+
+ {/* Was a fixed https://zerodev.app on all 249 pages, which told every
+ crawler and every share preview that the whole site was one URL. */}
+
+
+ {isUnmaintained(path) && (
+
+ )}
+
+ >
+ );
+ },
// Pillars (Getting Started, Onboarding, Onramp, Smart Account, Advanced,
// API & Tooling) are rendered by the `inject-pillar-bar` Vite plugin
// below as a separate horizontal bar BELOW this top nav. Only utility links
From 3c2f316d50af1d1876bf40253708a1b0e5e324cc Mon Sep 17 00:00:00 2001
From: Jayesh Bhole <54071350+jayeshbhole@users.noreply.github.com>
Date: Tue, 1 Sep 2026 20:29:49 +0530
Subject: [PATCH 2/6] chore: drop the v5.3.x pointer from the migration guide
---
docs/pages/advanced/migration.mdx | 2 --
1 file changed, 2 deletions(-)
diff --git a/docs/pages/advanced/migration.mdx b/docs/pages/advanced/migration.mdx
index d5d1d9b3..a2ef7daf 100644
--- a/docs/pages/advanced/migration.mdx
+++ b/docs/pages/advanced/migration.mdx
@@ -35,8 +35,6 @@ In version **5.4.x** of the `@zerodev/sdk`, we've migrated to using `viem@2.18.x
This guide will help you migrate your codebase to be compatible with the new version.
-Still on 5.3.x? The [v5.3.x documentation](/sdk/v5_3_x) stays online for reference.
-
### Update dependencies
1. **Remove the `permissionless` package**:
From 0e2da824e074f9efc8423f6fab6113ee8bd0feed Mon Sep 17 00:00:00 2001
From: Jayesh Bhole <54071350+jayeshbhole@users.noreply.github.com>
Date: Tue, 1 Sep 2026 20:56:50 +0530
Subject: [PATCH 3/6] fix: emit only the head tags vocs does not already emit
The head block was a JSX element, and vocs checks typeof head === object
before it checks for an element, so it took the path-prefix-map branch and
rendered nothing. og:type, og:title and og:url never reached a page.
Keeping our own og:type/og:title/og:description now would duplicate the
per-page ones vocs emits, so drop them and keep the canonical, og:url and
the noindex flag.
Also records why /global-address takes two hops: a Cloudflare rule answers
it before the request reaches this server.
---
redirects.config.js | 7 +++++++
vocs.config.tsx | 23 +++++++++++------------
2 files changed, 18 insertions(+), 12 deletions(-)
diff --git a/redirects.config.js b/redirects.config.js
index 09e2c486..1af27a93 100644
--- a/redirects.config.js
+++ b/redirects.config.js
@@ -125,6 +125,13 @@ export const redirects = [
{ from: "/recovery-flow/portal", to: "/advanced/account-recovery/portal" },
// Smart Routing Address (and global-address duplicate)
+ //
+ // /global-address never reaches this server: a Cloudflare rule answers it
+ // first with a 301 to /smart-routing-address, which this table then sends on,
+ // so a reader takes two hops. The rule below is the target we want, and it
+ // takes effect the moment that Cloudflare rule is removed (DES-24). Confirmed
+ // on production: that response carries no `x-render-origin-server` header,
+ // while every rule we own does.
{ from: "/smart-routing-address", to: "/onramp/smart-routing-address/quickstart" },
{ from: "/global-address", to: "/onramp/smart-routing-address/quickstart" },
diff --git a/vocs.config.tsx b/vocs.config.tsx
index 8ed5f3b0..98db84c6 100644
--- a/vocs.config.tsx
+++ b/vocs.config.tsx
@@ -113,26 +113,25 @@ export default defineConfig({
title: "ZeroDev",
titleTemplate: "%s – ZeroDev",
description: "The most powerful smart account.",
+ // Must stay a function. Vocs tests `typeof head === "object"` before it
+ // tests for an element, and a JSX element is an object, so it took the
+ // "map of path prefix to element" branch, found no key matching the route
+ // and rendered nothing. The og:type, og:title and og:description that used
+ // to sit here never reached a single page; vocs emits its own, per page,
+ // from `title` and `description` above. Only the tags vocs does not emit
+ // belong here.
head({ path }) {
const canonical = ORIGIN + canonicalPath(path);
return (
<>
-
-
- {/* Was a fixed https://zerodev.app on all 249 pages, which told every
- crawler and every share preview that the whole site was one URL. */}
-
+ {/* Vocs only emits og:url when `baseUrl` is set, and setting that also
+ emits a tag that would change how every relative link
+ resolves. Cheaper to write the tag directly. */}
+
{isUnmaintained(path) && (
)}
-
>
);
},
From 9f044677cd12de37a76c9758223474c95a0f9f50 Mon Sep 17 00:00:00 2001
From: Jayesh Bhole <54071350+jayeshbhole@users.noreply.github.com>
Date: Tue, 1 Sep 2026 21:05:12 +0530
Subject: [PATCH 4/6] feat: fall back to a site description where a page has
none
72 of the 135 indexable pages carry no description, so Google writes its own
snippet and a shared link previews bare. Vocs only emits one when the page
supplies it, through frontmatter or the bracketed suffix on the H1.
Collects the routes that already have one and fills only the gap, so no page
ends up with two. A stopgap: one description across 72 pages is weak, and
each page still needs its own (DES-25).
---
vocs.config.tsx | 57 ++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 56 insertions(+), 1 deletion(-)
diff --git a/vocs.config.tsx b/vocs.config.tsx
index 98db84c6..504288d2 100644
--- a/vocs.config.tsx
+++ b/vocs.config.tsx
@@ -1,4 +1,5 @@
-import { resolve } from "node:path";
+import { readFileSync, readdirSync } from "node:fs";
+import { join, resolve } from "node:path";
import { defineConfig } from "vocs";
import dotenv from "dotenv";
import { resolveRedirect } from "./resolve-redirect.js";
@@ -40,6 +41,54 @@ function canonicalPath(path: string) {
const isUnmaintained = (path: string) =>
path.startsWith("/advanced/react-hooks/");
+// The copy that sat in the dead og:description tag below. It never reached a
+// page, so this is the first time it ships.
+const SITE_DESCRIPTION =
+ "Build a Web3 experience that feels like Web2, using account abstraction through ZeroDev. Say goodbye to gas, seed phrases, transaction prompts, and more.";
+
+// Vocs emits a description only when the page's own frontmatter carries one,
+// and 72 of the 135 indexable pages do not, so they ship with no description
+// and no og:description at all. Google then writes its own snippet, and a
+// shared link previews bare.
+//
+// This is a stopgap, not a fix: one description repeated across 72 pages is
+// weak, and Google may still write its own. Each page needs its own (DES-25).
+// Collect the routes that already have one, so the fallback never doubles up
+// with the tag vocs emits.
+const PAGES_DIR = resolve(process.cwd(), "docs/pages");
+
+// Vocs reads a description from two places: `description:` in the frontmatter,
+// or a bracketed suffix on the H1, as in `# Social Login [Social login lets
+// users sign in with...]`. Miss the second and a page gets two descriptions.
+// The trailing-bracket test excludes a markdown link, which is `[text](url)`.
+function hasDescription(src: string) {
+ const close = src.startsWith("---") ? src.indexOf("\n---", 3) : -1;
+ if (close > 0 && /^description:\s*\S/m.test(src.slice(3, close))) return true;
+ const h1 = src.match(/^#[^#\n].*$/m)?.[0];
+ return Boolean(h1 && /\[[^\]]+\]\s*$/.test(h1));
+}
+
+function routesWithDescription(dir = PAGES_DIR, prefix = "") {
+ const out = new Set();
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ for (const r of routesWithDescription(full, `${prefix}/${entry.name}`)) {
+ out.add(r);
+ }
+ continue;
+ }
+ if (!/\.mdx?$/.test(entry.name)) continue;
+ const src = readFileSync(full, "utf8");
+ if (!hasDescription(src)) continue;
+ const name = entry.name.replace(/\.mdx?$/, "");
+ out.add(name === "index" ? prefix || "/" : `${prefix}/${name}`);
+ }
+ return out;
+}
+
+const HAS_DESCRIPTION = routesWithDescription();
+
export default defineConfig({
iconUrl: "/favicon.ico",
logoUrl: {
@@ -129,6 +178,12 @@ export default defineConfig({
emits a tag that would change how every relative link
resolves. Cheaper to write the tag directly. */}
+ {!HAS_DESCRIPTION.has(path) && (
+ <>
+
+
+ >
+ )}
{isUnmaintained(path) && (
)}
From 365b7077b626b1c88e42d5e4d23f086821c30dca Mon Sep 17 00:00:00 2001
From: Jayesh Bhole <54071350+jayeshbhole@users.noreply.github.com>
Date: Tue, 1 Sep 2026 21:06:32 +0530
Subject: [PATCH 5/6] chore: point the code comments at the right tickets
---
redirects.config.js | 2 +-
vocs.config.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/redirects.config.js b/redirects.config.js
index 1af27a93..4ccaf737 100644
--- a/redirects.config.js
+++ b/redirects.config.js
@@ -129,7 +129,7 @@ export const redirects = [
// /global-address never reaches this server: a Cloudflare rule answers it
// first with a 301 to /smart-routing-address, which this table then sends on,
// so a reader takes two hops. The rule below is the target we want, and it
- // takes effect the moment that Cloudflare rule is removed (DES-24). Confirmed
+ // takes effect the moment that Cloudflare rule is removed (DES-25). Confirmed
// on production: that response carries no `x-render-origin-server` header,
// while every rule we own does.
{ from: "/smart-routing-address", to: "/onramp/smart-routing-address/quickstart" },
diff --git a/vocs.config.tsx b/vocs.config.tsx
index 504288d2..3bd6bafd 100644
--- a/vocs.config.tsx
+++ b/vocs.config.tsx
@@ -52,7 +52,7 @@ const SITE_DESCRIPTION =
// shared link previews bare.
//
// This is a stopgap, not a fix: one description repeated across 72 pages is
-// weak, and Google may still write its own. Each page needs its own (DES-25).
+// weak, and Google may still write its own. Each page needs its own (DES-24).
// Collect the routes that already have one, so the fallback never doubles up
// with the tag vocs emits.
const PAGES_DIR = resolve(process.cwd(), "docs/pages");
From 25750b333217d6022bd8f53195466824bf073417 Mon Sep 17 00:00:00 2001
From: Jayesh Bhole <54071350+jayeshbhole@users.noreply.github.com>
Date: Tue, 1 Sep 2026 21:12:28 +0530
Subject: [PATCH 6/6] fix: fail the sitemap build when a page has no canonical
A page with no canonical tag was listed, which contradicts the rule the
script states and hides the exact regression that already happened once:
vocs drops the whole head config silently.
Also trims the comments and drops the ticket numbers.
---
docs/public/404.html | 5 ++-
redirects.config.js | 9 ++----
scripts/check-redirects.mjs | 13 +++-----
scripts/sitemap.mjs | 44 +++++++++++++++++++-------
vocs.config.tsx | 61 ++++++++++++-------------------------
5 files changed, 62 insertions(+), 70 deletions(-)
diff --git a/docs/public/404.html b/docs/public/404.html
index fe2ef648..777ebdec 100644
--- a/docs/public/404.html
+++ b/docs/public/404.html
@@ -7,9 +7,8 @@
Page not found – ZeroDev