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) => ` ${escapeHtml(l)}`).join("\n"); + return `\n\n${body}\n\n`; +} + async function detectProjectType(projectDir) { const configPath = join(projectDir, "template.config.json"); if (existsSync(configPath)) { @@ -320,12 +355,30 @@ async function buildLandingPage(projects, externals) { const totalCount = defaultEntries.length + [...sectionMap.values()].reduce((a, l) => a + l.length, 0); + const siteUrl = getSiteUrl(); + const ogImage = siteUrl && existsSync(join(ROOT, "public", "og.png")) ? `${siteUrl}/og.png` : ""; + const headExtra = [ + ` `, + ` `, + ` `, + siteUrl && ` `, + ` `, + ` `, + ` `, + siteUrl && ` `, + ogImage && ` `, + ` `, + ` `, + ` `, + ].filter(Boolean).join("\n"); + const html = ` Runflow Templates +${headExtra} @@ -474,6 +527,32 @@ async function buildLandingPage(projects, externals) { font-family: 'Space Mono', ui-monospace, monospace; color: #FBBF24; } + + .cta { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-top: 1rem; + padding: 0.5rem 0.9rem; + background: #FBBF24; + color: #09090B; + font-size: 0.8125rem; + font-weight: 700; + border-radius: 8px; + text-decoration: none; + transition: background 0.15s; + } + .cta:hover { background: #FCD34D; } + + footer { + margin-top: 4rem; + padding-top: 1.5rem; + border-top: 1px solid #27272A; + color: #71717A; + font-size: 0.8125rem; + } + footer a { color: #FBBF24; text-decoration: none; } + footer a:hover { color: #FDE68A; } @@ -484,10 +563,13 @@ async function buildLandingPage(projects, externals) {

Runflow Templates

${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("https://x.test/")); + assert.ok(xml.includes("https://x.test/a/")); + assert.ok(!xml.includes("/b/")); +}); + +test("buildRobotsTxt allows all and includes Sitemap only when site URL is known", () => { + assert.ok(buildRobotsTxt("https://x.test").includes("Sitemap: https://x.test/sitemap.xml")); + assert.ok(!buildRobotsTxt("").includes("Sitemap:")); +});