Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
107 changes: 106 additions & 1 deletion build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ` <url><loc>${escapeHtml(l)}</loc></url>`).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
}

async function detectProjectType(projectDir) {
const configPath = join(projectDir, "template.config.json");
if (existsSync(configPath)) {
Expand Down Expand Up @@ -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 = [
` <meta name="description" content="${escapeHtml(SITE_DESCRIPTION)}">`,
` <meta name="theme-color" content="#09090B">`,
` <link rel="icon" href="/favicon.svg" type="image/svg+xml">`,
siteUrl && ` <link rel="canonical" href="${escapeHtml(siteUrl + "/")}">`,
` <meta property="og:title" content="Runflow Templates">`,
` <meta property="og:description" content="${escapeHtml(SITE_DESCRIPTION)}">`,
` <meta property="og:type" content="website">`,
siteUrl && ` <meta property="og:url" content="${escapeHtml(siteUrl + "/")}">`,
ogImage && ` <meta property="og:image" content="${escapeHtml(ogImage)}">`,
` <meta name="twitter:card" content="${ogImage ? "summary_large_image" : "summary"}">`,
` <meta name="twitter:title" content="Runflow Templates">`,
` <meta name="twitter:description" content="${escapeHtml(SITE_DESCRIPTION)}">`,
].filter(Boolean).join("\n");

const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Runflow Templates</title>
${headExtra}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700;800&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
Expand Down Expand Up @@ -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; }
</style>
</head>
<body>
Expand All @@ -484,10 +563,13 @@ async function buildLandingPage(projects, externals) {
</div>
<h1>Run<span>flow</span> Templates</h1>
<p class="subtitle">${totalCount} template${totalCount !== 1 ? "s" : ""}</p>
<a class="cta" href="${RUNFLOW_CTA_URL}" target="_blank" rel="noopener">Build with Runflow ↗</a>
</header>
${sectionBlocks}
${defaultBlock}
<footer>Built with <a href="${RUNFLOW_CTA_URL}" target="_blank" rel="noopener">Runflow</a> · <a href="https://github.com/runflow-io/templates" target="_blank" rel="noopener">Source on GitHub</a></footer>
</div>
<script defer src="/_vercel/insights/script.js"></script>
</body>
</html>`;

Expand Down Expand Up @@ -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(/<head([^>]*)>/i, `<head$1>\n <meta name="robots" content="noindex">`));
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))` : ""}.`);
Expand All @@ -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)) {
Expand Down
10 changes: 10 additions & 0 deletions public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 17 additions & 1 deletion test/build.test.mjs
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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("<loc>https://x.test/</loc>"));
assert.ok(xml.includes("<loc>https://x.test/a/</loc>"));
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:"));
});