diff --git a/.env.example b/.env.example
index 27a43b3..9953cbf 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,13 @@
# Local dev: copy this file to `.env`. Never commit a real `.env`.
# Production secrets belong in your host's environment-variable settings.
+# ── Site URL (SEO) ───────────────────────────────────────
+# Absolute origin of the deployed hub, used for canonical, og:url, and
+# sitemap.xml. On Vercel this falls back to VERCEL_PROJECT_PRODUCTION_URL
+# automatically; set it explicitly for a custom domain, e.g.
+# SITE_URL=https://templates.runflow.io
+SITE_URL=
+
# ── node-functions cron auth ─────────────────────────────
# Only needed for `node-functions` projects that declare cron jobs. The host
# (e.g. Vercel) injects "Authorization: Bearer ${CRON_SECRET}" on each fire and
diff --git a/README.md b/README.md
index 9e283e2..0ceaae2 100644
--- a/README.md
+++ b/README.md
@@ -148,7 +148,11 @@ Designed for **Vercel** (or any host that understands the Build Output API v3).
- **Build command**: `npm run build`
- **Output**: `.vercel/output/` (static assets + functions + `config.json`)
- **Clean URLs**: `/example-next/about` resolves to `…/about.html`
-- **Indexable**: no `noindex` headers — pages are public and crawlable
+- **Indexable**: public and crawlable. Set `SITE_URL` (or rely on Vercel's
+ production URL) and the build emits `robots.txt` + `sitemap.xml` and adds
+ canonical/Open Graph tags. A template can opt out of indexing with
+ `"noindex": true` in its `template.config.json` (it's dropped from the sitemap
+ and gets a `robots` meta).
### Setup on Vercel
diff --git a/build.mjs b/build.mjs
index e81d7e1..e921e61 100644
--- a/build.mjs
+++ b/build.mjs
@@ -103,6 +103,41 @@ function safeHref(href) {
return "#";
}
+// --- SEO --------------------------------------------------------------------
+const SITE_DESCRIPTION =
+ "Ready-to-run Runflow templates and demos — static, React/Vite, Nuxt, and " +
+ "Next.js starters plus serverless examples, built and deployed together.";
+const RUNFLOW_CTA_URL = "https://runflow.io/?utm_source=templates&utm_medium=hub";
+
+// Absolute origin of the deployed hub (for canonical / og:url / sitemap).
+// SITE_URL wins; on Vercel it falls back to the production URL; else "".
+function getSiteUrl() {
+ const raw =
+ process.env.SITE_URL ||
+ (process.env.VERCEL_PROJECT_PRODUCTION_URL
+ ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
+ : "");
+ return raw.replace(/\/+$/, "");
+}
+
+function buildRobotsTxt(siteUrl) {
+ let out = "User-agent: *\nAllow: /\n";
+ if (siteUrl) out += `\nSitemap: ${siteUrl}/sitemap.xml\n`;
+ return out;
+}
+
+// Sitemap of the landing page + every built project root, minus opt-out
+// (template.config.json `"noindex": true`) projects.
+function buildSitemap(siteUrl, projects, noindexNames = new Set()) {
+ const locs = [`${siteUrl}/`];
+ for (const p of projects) {
+ if (noindexNames.has(p.name)) continue;
+ locs.push(`${siteUrl}/${p.name}/`);
+ }
+ const body = locs.map((l) => `
${totalCount} template${totalCount !== 1 ? "s" : ""}
+ Build with Runflow ↗ ${sectionBlocks} ${defaultBlock} + + `; @@ -732,6 +814,29 @@ async function main() { await cp(faviconSrc, join(VERCEL_OUTPUT, "static", "favicon.ico")); } + // Selective noindex: templates that opt out via template.config.json + // `"noindex": true` get a robots meta injected and are dropped from the sitemap. + const noindexNames = new Set(built.filter((p) => p.config?.noindex).map((p) => p.name)); + for (const name of noindexNames) { + const indexPath = join(VERCEL_OUTPUT, "static", name, "index.html"); + if (!existsSync(indexPath)) continue; + const html = await readFile(indexPath, "utf-8"); + if (!/name=["']robots["']/i.test(html)) { + await writeFile(indexPath, html.replace(/]*)>/i, `\n `)); + console.log(` [${name}] Marked noindex`); + } + } + + // robots.txt (always) + sitemap.xml (when the site URL is known) + const siteUrl = getSiteUrl(); + await writeFile(join(VERCEL_OUTPUT, "static", "robots.txt"), buildRobotsTxt(siteUrl)); + if (siteUrl) { + await writeFile(join(VERCEL_OUTPUT, "static", "sitemap.xml"), buildSitemap(siteUrl, built, noindexNames)); + console.log(`robots.txt + sitemap.xml generated (${siteUrl}).`); + } else { + console.log("robots.txt generated — set SITE_URL for canonical URLs + sitemap.xml."); + } + // Generate .vercel/output/config.json await generateVercelConfig(built, allCrons); console.log(`Vercel config generated${allCrons.length > 0 ? ` (${allCrons.length} cron(s))` : ""}.`); @@ -740,7 +845,7 @@ async function main() { } // Exported for unit tests (test/build.test.mjs). -export { buildVercelConfig, escapeHtml, safeHref, isValidProjectName, detectProjectType }; +export { buildVercelConfig, escapeHtml, safeHref, isValidProjectName, detectProjectType, buildSitemap, buildRobotsTxt }; // Only run the build when executed directly (`node build.mjs`), not when imported. if (process.argv[1] === fileURLToPath(import.meta.url)) { diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..053d053 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,10 @@ + diff --git a/test/build.test.mjs b/test/build.test.mjs index 07d5cd6..672fcb9 100644 --- a/test/build.test.mjs +++ b/test/build.test.mjs @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildVercelConfig, escapeHtml, safeHref, isValidProjectName } from "../build.mjs"; +import { buildVercelConfig, escapeHtml, safeHref, isValidProjectName, buildSitemap, buildRobotsTxt } from "../build.mjs"; import { collectCronEntries } from "../build-node-functions.mjs"; test("escapeHtml escapes HTML-significant characters", () => { @@ -85,3 +85,19 @@ test("collectCronEntries prefixes project name and forces a trailing slash", () test("collectCronEntries throws when a cron is missing path or schedule", () => { assert.throws(() => collectCronEntries("p", { crons: [{ path: "/x" }] }), /schedule/); }); + +test("buildSitemap lists the landing + project roots and skips noindex projects", () => { + const xml = buildSitemap( + "https://x.test", + [{ name: "a", config: {} }, { name: "b", config: { noindex: true } }], + new Set(["b"]) + ); + assert.ok(xml.includes("