From 338146b42ae3a8188cae81ddcde0a1b7e0424484 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 3 Jun 2026 09:48:49 +0000 Subject: [PATCH 1/2] feat: add template scaffolder and config schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run new -- ` (or interactive) scaffolds a slug-named starter for any of the 7 types with the base-path wiring and a $schema-referenced template.config.json where needed — so contributors don't reverse-engineer the conventions. template.config.schema.json gives editor validation/autocomplete for custom + node-functions configs. --- package.json | 3 +- scripts/new.mjs | 199 ++++++++++++++++++++++++++++++++++++ template.config.schema.json | 94 +++++++++++++++++ 3 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 scripts/new.mjs create mode 100644 template.config.schema.json diff --git a/package.json b/package.json index 5a4b7be..657b7f4 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "node build.mjs", "dev": "node dev.mjs", - "test": "node --test" + "test": "node --test", + "new": "node scripts/new.mjs" }, "devDependencies": { "esbuild": "^0.28.0" diff --git a/scripts/new.mjs b/scripts/new.mjs new file mode 100644 index 0000000..d81a93d --- /dev/null +++ b/scripts/new.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/** + * Scaffold a new template under projects/. + * + * npm run new -- + * npm run new # interactive + * + * is one of: static (default), vite, next, nuxt, nuxt-server, + * custom, node-functions. + */ +import { mkdir, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, resolve, dirname } from "node:path"; +import { createInterface } from "node:readline/promises"; +import { stdin, stdout, argv, exit } from "node:process"; + +const ROOT = resolve(import.meta.dirname, ".."); +const PROJECTS = join(ROOT, "projects"); +const TYPES = ["static", "vite", "next", "nuxt", "nuxt-server", "custom", "node-functions"]; +const VALID_NAME = /^[a-z0-9][a-z0-9-]*$/; + +let [name, type] = argv.slice(2); + +if (!name || !type) { + const rl = createInterface({ input: stdin, output: stdout }); + if (!name) name = (await rl.question("Template name (slug): ")).trim(); + if (!type) type = (await rl.question(`Type [${TYPES.join(" / ")}] (static): `)).trim() || "static"; + rl.close(); +} + +if (!VALID_NAME.test(name)) { + console.error(`✗ Invalid name "${name}" — use lowercase letters, digits, and hyphens (e.g. my-template).`); + exit(1); +} +if (!TYPES.includes(type)) { + console.error(`✗ Unknown type "${type}". Choose one of: ${TYPES.join(", ")}`); + exit(1); +} + +const dir = join(PROJECTS, name); +if (existsSync(dir)) { + console.error(`✗ projects/${name} already exists.`); + exit(1); +} + +for (const [rel, content] of Object.entries(stubs(name, type))) { + const full = join(dir, rel); + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, content); +} + +console.log(`✓ Created projects/${name}/ (${type})`); +console.log(` Next: ${needsInstall(type) ? `cd projects/${name} && npm install, then ` : ""}npm run build`); + +// --- stubs ------------------------------------------------------------------ + +function needsInstall(t) { + return ["vite", "next", "nuxt", "nuxt-server", "custom", "node-functions"].includes(t); +} + +const SCHEMA_REF = "../../template.config.schema.json"; + +// Shared brand-styled page used by the static + vite stubs. +function htmlStub(title) { + return ` + + + + + ${title} + + + +

${title} · Runflow

+

New template scaffolded with npm run new. Edit me.

+ + +`; +} + +function pkg(extra) { + return JSON.stringify({ name, private: true, ...extra }, null, 2) + "\n"; +} + +function config(obj) { + return JSON.stringify({ $schema: SCHEMA_REF, ...obj }, null, 2) + "\n"; +} + +function stubs(name, type) { + switch (type) { + case "static": + return { "index.html": htmlStub(name) }; + + case "vite": + return { + "package.json": pkg({ + scripts: { dev: "vite", build: "vite build" }, + devDependencies: { vite: "^5.0.0" }, + }), + "index.html": ` + +${name} + +
+ + + +`, + "src/main.js": `document.querySelector("#app").innerHTML = "

${name} · Runflow

Vite template. Edit src/main.js.

";\n`, + }; + + case "next": + return { + "package.json": pkg({ + scripts: { dev: "next dev", build: "next build" }, + dependencies: { next: "^15.5.0", react: "^19.0.0", "react-dom": "^19.0.0" }, + }), + "next.config.mjs": `/** @type {import("next").NextConfig} */ +const config = { + output: "export", + basePath: process.env.NEXT_PUBLIC_BASE_PATH || "", + images: { unoptimized: true }, + outputFileTracingRoot: import.meta.dirname, +}; +export default config; +`, + "app/layout.jsx": `export const metadata = { title: "${name}" }; +export default function RootLayout({ children }) { + return ({children}); +} +`, + "app/page.jsx": `export default function Page() { + return (

${name} · Runflow

Next.js template. Edit app/page.jsx.

); +} +`, + }; + + case "nuxt": + case "nuxt-server": { + const files = { + "package.json": pkg({ + scripts: { dev: "nuxi dev", build: "nuxi build" }, + dependencies: { nuxt: "^3.13.0" }, + }), + "app.vue": ` +`, + }; + if (type === "nuxt-server") { + files["server/api/hello.ts"] = `export default defineEventHandler(() => ({ hello: "${name}" }));\n`; + } + return files; + } + + case "custom": + return { + "template.config.json": config({ type: "custom", title: name, outputDir: "dist" }), + "package.json": pkg({ scripts: { build: "node build.js" } }), + "build.js": `import { mkdir, writeFile } from "node:fs/promises"; +// BASE_PATH (=/) is provided by the hub; use it for asset prefixes. +const base = process.env.BASE_PATH || ""; +await mkdir("dist", { recursive: true }); +await writeFile("dist/index.html", \`${name}

${name} · Runflow

Custom build at base \${base}.

\`); +`, + }; + + case "node-functions": + return { + "template.config.json": config({ + type: "node-functions", + title: name, + dashboard: { dir: ".", buildCmd: "npm run build", outputDir: "dist" }, + functions: { entries: ["api/hello.mjs"], memory: 512, maxDuration: 30 }, + }), + "package.json": pkg({ scripts: { build: "node build.js" } }), + "build.js": `import { mkdir, writeFile } from "node:fs/promises"; +await mkdir("dist", { recursive: true }); +await writeFile("dist/index.html", \`${name}

${name} · Runflow

Dashboard. Calls api/hello.

\`); +`, + "api/hello.mjs": `export default function handler(req, res) { + res.status(200).json({ hello: "${name}", time: new Date().toISOString() }); +} +`, + }; + + default: + return { "index.html": htmlStub(name) }; + } +} diff --git a/template.config.schema.json b/template.config.schema.json new file mode 100644 index 0000000..0f6053e --- /dev/null +++ b/template.config.schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/runflow-io/templates/template.config.schema.json", + "title": "template.config.json", + "description": "Per-project config for a Runflow Templates entry under projects//.", + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "description": "Build type. Usually auto-detected; required for custom and node-functions.", + "enum": ["static", "vite", "next", "nuxt", "nuxt-server", "custom", "node-functions"] + }, + "title": { + "type": "string", + "description": "Human-readable title shown on the landing card." + }, + "noindex": { + "type": "boolean", + "description": "Opt this template out of indexing (adds a robots meta + drops it from sitemap.xml)." + }, + "outputDir": { + "type": "string", + "description": "For custom: directory (relative to the project) the build writes to. Default: dist." + }, + "section": { + "type": "string", + "description": "Group this project's demos under a named section on the landing page." + }, + "demos": { + "type": "array", + "description": "Sub-entries rendered as individual cards under `section`.", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "path": { "type": "string" } + }, + "additionalProperties": true + } + }, + "dashboard": { + "type": "object", + "description": "node-functions: the static dashboard built alongside the functions.", + "properties": { + "dir": { "type": "string", "description": "Project-relative dir to build from. Default: '.'" }, + "buildCmd": { "type": "string", "description": "Command to build the dashboard. Default: 'npm run build'." }, + "outputDir": { "type": "string", "description": "Built output dir copied to dist//." } + }, + "additionalProperties": false + }, + "functions": { + "type": "object", + "description": "node-functions: serverless functions bundled with esbuild.", + "properties": { + "entries": { + "type": "array", + "description": "Handler files (.mjs/.js), project-relative.", + "items": { "type": "string" } + }, + "memory": { "type": "integer", "minimum": 128 }, + "maxDuration": { "type": "integer", "minimum": 1 }, + "perEntry": { + "type": "object", + "description": "Per-entry overrides keyed by the entry path.", + "additionalProperties": { + "type": "object", + "properties": { + "memory": { "type": "integer", "minimum": 128 }, + "maxDuration": { "type": "integer", "minimum": 1 }, + "catchAll": { "type": "boolean" } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "crons": { + "type": "array", + "description": "node-functions: cron schedules. `path` is project-relative; the hub prepends /.", + "items": { + "type": "object", + "required": ["path", "schedule"], + "properties": { + "path": { "type": "string" }, + "schedule": { "type": "string", "description": "Standard cron expression." } + }, + "additionalProperties": false + } + } + } +} From b717471ca7b858abce4d56bc5c010d6247f47e21 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 3 Jun 2026 09:48:49 +0000 Subject: [PATCH 2/2] docs: document the scaffolder and template.config schema --- CLAUDE.md | 10 ++++++---- README.md | 25 ++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 68bf1ad..ecb24e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,10 +37,12 @@ type, builds them in parallel, and assembles everything into `.vercel/output/` ## Adding a template -1. Create `projects//`. -2. Add project files (HTML, or a `package.json` with framework deps). -3. For framework projects: `cd projects/ && npm install`. -4. `npm run build` to test. +- Scaffold: `npm run new -- ` (types: static, vite, next, nuxt, + nuxt-server, custom, node-functions). Or interactively: `npm run new`. +- By hand: create `projects//` (name must be a slug `[a-z0-9-]`), add files + (HTML, or a `package.json` with framework deps + lockfile), then `npm run build`. +- `custom` / `node-functions` use `template.config.json` (validated by + `template.config.schema.json` — reference it via `"$schema": "../../template.config.schema.json"`). ## Branding diff --git a/README.md b/README.md index 0ceaae2..8e16c3e 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,30 @@ npm run dev ## Adding a new template -1. Create a folder in `projects/` with your project files. -2. If it needs a build step (React, Vue, Next, Nuxt, etc.), include a `package.json`. +Fastest way — scaffold a starter: + +```bash +npm run new -- my-thing next # name + type +npm run new # interactive +``` + +Types: `static` (default), `vite`, `next`, `nuxt`, `nuxt-server`, `custom`, `node-functions`. + +Or by hand: + +1. Create a folder in `projects/` with your project files. The name must be a + slug (`^[a-z0-9][a-z0-9-]*$`) — it becomes the URL path (`projects/my-thing/` + → `/my-thing/`). Non-slug folders are skipped by the build. +2. If it needs a build step (React, Vue, Next, Nuxt, etc.), include a + `package.json` and a committed lockfile. 3. Run `npm run build` from the root — it auto-detects the type and builds everything. -That's it. The folder name becomes the URL path (`projects/my-thing/` → `/my-thing/`). +`custom` and `node-functions` projects use a `template.config.json`. Reference +the schema for editor validation: + +```json +{ "$schema": "../../template.config.schema.json", "type": "custom", "outputDir": "build" } +``` ### Project type detection