#!/usr/bin/env node import { execSync } from "child_process"; import fs from "fs"; import path from "path"; import { createInterface } from "readline"; // ============================================================================= // Terminal formatting (zero deps) // ============================================================================= const fmt = { bold: (s) => `\x1b[1m${s}\x1b[22m`, green: (s) => `\x1b[32m${s}\x1b[39m`, cyan: (s) => `\x1b[36m${s}\x1b[39m`, red: (s) => `\x1b[31m${s}\x1b[39m`, dim: (s) => `\x1b[2m${s}\x1b[22m`, yellow: (s) => `\x1b[33m${s}\x1b[39m`, }; const TEMPLATE_REPO = "https://huggingface.co/spaces/tfrere/research-article-template"; // ============================================================================= // Prompt helpers // ============================================================================= function createPrompt() { const rl = createInterface({ input: process.stdin, output: process.stdout }); const ask = (question, defaultValue) => new Promise((resolve) => { const hint = defaultValue ? ` ${fmt.dim(`[${defaultValue}]`)}` : ""; rl.question(` ${question}${hint}: `, (answer) => { resolve(answer.trim() || defaultValue || ""); }); }); const select = (question, options) => new Promise((resolve) => { console.log(`\n ${question}\n`); options.forEach((opt, i) => { const marker = i === 0 ? fmt.cyan(">") : " "; const hint = opt.hint ? fmt.dim(` - ${opt.hint}`) : ""; console.log(` ${marker} ${fmt.bold(String(i + 1))}. ${opt.label}${hint}`); }); const tryRead = () => { rl.question(`\n Choice ${fmt.dim(`[1-${options.length}]`)}: `, (answer) => { const idx = parseInt(answer, 10) - 1; if (idx >= 0 && idx < options.length) { resolve(options[idx].value); } else if (answer.trim() === "") { resolve(options[0].value); } else { tryRead(); } }); }; tryRead(); }); const confirm = (question, defaultYes = true) => new Promise((resolve) => { const hint = defaultYes ? fmt.dim(" [Y/n]") : fmt.dim(" [y/N]"); rl.question(` ${question}${hint}: `, (answer) => { const a = answer.trim().toLowerCase(); if (a === "") resolve(defaultYes); else resolve(a === "y" || a === "yes"); }); }); return { rl, ask, select, confirm, close: () => rl.close() }; } // ============================================================================= // Content generators // ============================================================================= function todayFormatted() { const d = new Date(); const months = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; return `${months[d.getMonth()]}. ${String(d.getDate()).padStart(2, "0")}, ${d.getFullYear()}`; } function generateArticleMdx(cfg) { const authorLine = cfg.authorUrl ? ` - name: "${cfg.authorName}"\n url: "${cfg.authorUrl}"\n affiliations: [1]` : ` - name: "${cfg.authorName}"\n affiliations: [1]`; const affiliationLine = cfg.affiliationUrl ? ` - name: "${cfg.affiliationName}"\n url: "${cfg.affiliationUrl}"` : ` - name: "${cfg.affiliationName}"`; const isArticle = cfg.layout === "article"; return `--- title: "${cfg.title}" description: "${cfg.description}" authors: ${authorLine} affiliations: ${affiliationLine} published: "${todayFormatted()}" template: "${cfg.layout}" tableOfContentsAutoCollapse: ${isArticle} showPdf: ${isArticle} --- import Introduction from "./chapters/introduction.mdx"; `; } function generateIntroChapter(layout) { if (layout === "article") { return `## Abstract Write your abstract here. This section should briefly summarize the key points of your research. ## Introduction Start writing your article here. You can use all the features of the template: - **Markdown** for text formatting - **KaTeX** for math: $E = mc^2$ - **Citations** using BibTeX references [@example2025] - **D3.js embeds** for interactive visualizations - **Images** with automatic figure numbering See the [template documentation](https://huggingface.co/spaces/tfrere/research-article-template) for more details. `; } return `## Introduction Start writing your paper here. You can use all the features of the template: - **Markdown** for text formatting - **KaTeX** for math: $E = mc^2$ - **Citations** using BibTeX references [@example2025] - **D3.js embeds** for interactive visualizations - **Images** with automatic figure numbering See the [template documentation](https://huggingface.co/spaces/tfrere/research-article-template) for more details. `; } function generateBibliography() { return `@article{example2025, title={Example Reference}, author={Author, Example}, journal={Journal of Examples}, year={2025} } `; } function spaceIdToUrl(spaceId) { return `https://${spaceId.replace("/", "-").toLowerCase()}.hf.space`; } function generateReadme(name, layout, title, spaceId) { const layoutLabel = layout === "article" ? "Research Article" : "Research Paper"; const safeTitle = title || name; const thumbLine = spaceId ? `\nthumbnail: ${spaceIdToUrl(spaceId)}/thumb.auto.jpg` : ""; return `--- title: "${safeTitle}" emoji: 📝 colorFrom: blue colorTo: indigo sdk: docker app_port: 8080 header: mini pinned: false tags: - research-article-template${thumbLine} --- # ${name} A ${layoutLabel.toLowerCase()} built with [research-article-template](https://huggingface.co/spaces/tfrere/research-article-template). ## Quick start \`\`\`bash cd app npm run dev # dev server at http://localhost:4321 \`\`\` ## Deploy to Hugging Face \`\`\`bash # 1. Create a Space at huggingface.co/new-space (select Docker SDK) # 2. Push to it: git remote add space git@hf.co:spaces// git push space main \`\`\` That's it. The Dockerfile and nginx config are included. ## Edit your content | File | What | |------|------| | \`app/src/content/article.mdx\` | Main article (metadata + chapter imports) | | \`app/src/content/chapters/\` | Your chapters (one .mdx per section) | | \`app/src/content/bibliography.bib\` | BibTeX references | | \`app/src/content/embeds/\` | D3.js HTML visualizations | | \`app/src/content/assets/data/\` | CSV/JSON data files | | \`app/src/content/assets/image/\` | Images | ## Commands | Command | Description | |---------|-------------| | \`npm run dev\` | Dev server | | \`npm run build\` | Production build | | \`npm run export:pdf\` | Export as PDF | | \`npm run export:latex\` | Export as LaTeX | | \`npm run sync:template\` | Pull latest template updates | ## License CC-BY-4.0 `; } // ============================================================================= // Cleanup - remove demo content from the cloned template // ============================================================================= const CLEANUP_DIRS = [ ".git", ".cursor/rules", ".vscode", ".playwright-mcp", "app/.claude", "app/venv", "app/screenshots", "app/src/content/chapters/demo", "app/src/content/embeds/demo", "app/src/content/embeds/arxiv", "app/src/content/embeds/smol-playbook", "app/src/content/embeds/typography", "app/src/content/assets/audio", "app/src/content/assets/sprites", "app/src/components/demo", ]; const CLEANUP_FILES = [ "AUDIT-2026.md", "ROADMAP-2026.md", "CHANGELOG.md", "CONTRIBUTING.md", "prototype-project-page.html", "app/scripts/fetch-hf-citations.py", ]; const CLEANUP_GLOBS_IN = { "app/src/content/embeds": (name) => name !== "banner.html" && name.endsWith(".html"), "app/public": (name) => name.endsWith(".pdf"), }; function cleanupProject(dir) { // Remove known directories for (const p of CLEANUP_DIRS) { const full = path.join(dir, p); if (fs.existsSync(full)) fs.rmSync(full, { recursive: true, force: true }); } // Remove known files for (const p of CLEANUP_FILES) { const full = path.join(dir, p); if (fs.existsSync(full)) fs.rmSync(full, { force: true }); } // Remove matching files inside specific directories for (const [relDir, matcher] of Object.entries(CLEANUP_GLOBS_IN)) { const full = path.join(dir, relDir); if (!fs.existsSync(full)) continue; for (const entry of fs.readdirSync(full)) { if (matcher(entry)) { fs.rmSync(path.join(full, entry), { recursive: true, force: true }); } } } // Clear data dir but keep it const dataDir = path.join(dir, "app/src/content/assets/data"); if (fs.existsSync(dataDir)) { for (const f of fs.readdirSync(dataDir)) { if (f === ".gitkeep") continue; fs.rmSync(path.join(dataDir, f), { recursive: true, force: true }); } fs.writeFileSync(path.join(dataDir, ".gitkeep"), ""); } // Clear ALL images (LFS pointers are not valid image files) const imageDir = path.join(dir, "app/src/content/assets/image"); if (fs.existsSync(imageDir)) { for (const f of fs.readdirSync(imageDir)) { fs.rmSync(path.join(imageDir, f), { recursive: true, force: true }); } // Generate a minimal real placeholder PNG (1x1 white pixel) // so the project has a working example image const pngData = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQAB" + "Nl7BcQAAAABJRU5ErkJggg==", "base64", ); fs.writeFileSync(path.join(imageDir, "placeholder.png"), pngData); } // Remove all auto-generated and LFS thumbnails from public const publicDir = path.join(dir, "app/public"); if (fs.existsSync(publicDir)) { for (const f of fs.readdirSync(publicDir)) { if ( f.endsWith(".pdf") || f.startsWith("thumb.auto") || f === "thumb.png" ) { fs.rmSync(path.join(publicDir, f), { force: true }); } } } // Remove LFS-tracked .gitattributes entries that won't apply to new project const gitattrs = path.join(dir, ".gitattributes"); if (fs.existsSync(gitattrs)) { fs.rmSync(gitattrs, { force: true }); } } // ============================================================================= // Main // ============================================================================= async function main() { const args = process.argv.slice(2); if (args.includes("--help") || args.includes("-h")) { console.log(` ${fmt.bold("create-research-article")} Scaffold a new research paper or article. ${fmt.bold("Usage:")} npx create-research-article [project-name] [options] ${fmt.bold("Options:")} --template=article|paper Skip template selection prompt --help, -h Show this help message `); process.exit(0); } const positionalName = args.find((a) => !a.startsWith("-")); const templateFlag = args .find((a) => a.startsWith("--template=")) ?.split("=")[1] ?.toLowerCase(); console.log(""); console.log( ` ${fmt.bold("create-research-article")} ${fmt.dim("v0.2.0")}`, ); console.log(fmt.dim(" Scaffold a new research paper or article\n")); // Check git is available try { execSync("git --version", { stdio: "ignore" }); } catch { console.error(fmt.red(" Error: git is required but not installed.\n")); process.exit(1); } const prompt = createPrompt(); try { // 1. Project name const name = positionalName || (await prompt.ask("Project name", "my-research-article")); const targetDir = path.resolve(process.cwd(), name); if (fs.existsSync(targetDir)) { console.error( fmt.red(`\n Error: directory "${name}" already exists.\n`), ); prompt.close(); process.exit(1); } // 2. Layout let layout; if (templateFlag && ["article", "paper"].includes(templateFlag)) { layout = templateFlag; console.log(fmt.cyan(` Using template: ${layout}`)); } else { if (templateFlag) { console.log(fmt.yellow(` Warning: unknown template "${templateFlag}", showing picker.\n`)); } layout = await prompt.select("Choose a layout:", [ { value: "article", label: "Research Article", hint: "Full layout with banner, TOC, DOI, citations, PDF export", }, { value: "paper", label: "Research Paper", hint: "Lighter single-column layout, blog-friendly", }, ]); } // 3. Metadata console.log(""); const title = await prompt.ask("Title", "My Research Article"); const description = await prompt.ask("Short description", ""); const authorName = await prompt.ask("Author name", ""); const authorUrl = await prompt.ask("Author URL", ""); const affiliationName = await prompt.ask("Affiliation", ""); const affiliationUrl = await prompt.ask("Affiliation URL", ""); prompt.close(); // 4. Clone console.log(""); console.log(fmt.cyan(" Cloning template...")); execSync( `GIT_LFS_SKIP_SMUDGE=1 git clone --depth 1 "${TEMPLATE_REPO}" "${targetDir}"`, { stdio: "pipe" }, ); // 5. Cleanup console.log(fmt.cyan(" Cleaning up demo content...")); cleanupProject(targetDir); // 6. Generate content console.log(fmt.cyan(" Generating project files...")); fs.writeFileSync( path.join(targetDir, "app/src/content/article.mdx"), generateArticleMdx({ layout, title, description, authorName, authorUrl, affiliationName, affiliationUrl, }), ); // Rename chapter: remove old, create new const chaptersDir = path.join(targetDir, "app/src/content/chapters"); if (fs.existsSync(chaptersDir)) { for (const f of fs.readdirSync(chaptersDir)) { fs.rmSync(path.join(chaptersDir, f), { recursive: true, force: true }); } } else { fs.mkdirSync(chaptersDir, { recursive: true }); } fs.writeFileSync( path.join(chaptersDir, "introduction.mdx"), generateIntroChapter(layout), ); fs.writeFileSync( path.join(targetDir, "app/src/content/bibliography.bib"), generateBibliography(), ); fs.writeFileSync(path.join(targetDir, "README.md"), generateReadme(name, layout, title)); // 7. Init git execSync("git init", { cwd: targetDir, stdio: "pipe" }); execSync("git add -A", { cwd: targetDir, stdio: "pipe" }); execSync('git commit -m "Initial scaffold from create-research-article"', { cwd: targetDir, stdio: "pipe", }); // 8. Install dependencies console.log(fmt.cyan(" Installing dependencies (this may take a moment)...")); try { execSync("npm install", { cwd: path.join(targetDir, "app"), stdio: "pipe", }); console.log(fmt.green(" Dependencies installed.")); } catch { console.log( fmt.yellow( " Warning: npm install failed. Run it manually: cd app && npm install", ), ); } // 9. Optional: deploy to Hugging Face let deployed = false; const hasHfCli = (() => { try { execSync("huggingface-cli whoami", { stdio: "pipe" }); return true; } catch { return false; } })(); if (hasHfCli) { const prompt2 = createPrompt(); console.log(""); const wantDeploy = await prompt2.confirm( "Deploy to Hugging Face Spaces?", true, ); if (wantDeploy) { const hfUser = execSync("huggingface-cli whoami", { encoding: "utf8" }) .split("\n")[0] .trim(); const defaultSpace = `${hfUser}/${name}`; const spaceId = await prompt2.ask("Space ID", defaultSpace); prompt2.close(); console.log(fmt.cyan(` Creating Space ${spaceId}...`)); try { execSync( `huggingface-cli repo create "${spaceId}" --repo-type space --space_sdk docker --exist-ok`, { stdio: "pipe" }, ); execSync( `git remote add space "git@hf.co:spaces/${spaceId}"`, { cwd: targetDir, stdio: "pipe" }, ); // Update README thumbnail URL to point to the Space's own auto-generated thumb const readmePath = path.join(targetDir, "README.md"); const readmeContent = fs.readFileSync(readmePath, "utf8"); const thumbUrl = `${spaceIdToUrl(spaceId)}/thumb.auto.jpg`; if (!readmeContent.includes("thumbnail:")) { const updated = readmeContent.replace( /^(tags:\n(?:\s+-[^\n]+\n?)+)/m, `$1thumbnail: ${thumbUrl}\n`, ); fs.writeFileSync(readmePath, updated); } else { const updated = readmeContent.replace( /^thumbnail:.*$/m, `thumbnail: ${thumbUrl}`, ); fs.writeFileSync(readmePath, updated); } execSync("git add README.md && git commit --amend --no-edit", { cwd: targetDir, stdio: "pipe", }); console.log(fmt.cyan(" Pushing to Hugging Face...")); execSync("git push space main", { cwd: targetDir, stdio: "pipe", }); deployed = true; const spaceUrl = `https://huggingface.co/spaces/${spaceId}`; console.log(fmt.green(` Deployed! ${spaceUrl}`)); } catch (e) { console.log( fmt.yellow(` Warning: deploy failed. ${e.message}`), ); console.log( fmt.dim(" You can deploy manually later with: git push space main"), ); } } else { prompt2.close(); } } // 10. Success console.log(""); console.log(fmt.green(fmt.bold(" Done! Project created successfully."))); console.log(""); console.log(` ${fmt.bold("Next steps:")}`); console.log(""); console.log(fmt.cyan(` cd ${name}/app`)); console.log(fmt.cyan(" npm run dev")); if (!deployed && !hasHfCli) { console.log(""); console.log(` ${fmt.dim("To deploy to Hugging Face:")}`); console.log( fmt.dim( " pip install huggingface_hub && huggingface-cli login", ), ); console.log( fmt.dim( ` huggingface-cli repo create ${name} --repo-type space --space_sdk docker`, ), ); console.log( fmt.dim( ` cd ${name} && git remote add space git@hf.co:spaces//${name} && git push space main`, ), ); } else if (!deployed) { console.log(""); console.log(` ${fmt.dim("To deploy later:")}`); console.log( fmt.dim( ` cd ${name} && git remote add space git@hf.co:spaces//${name} && git push space main`, ), ); } console.log(""); } catch (err) { prompt.close(); console.error(fmt.red(`\n Error: ${err.message}\n`)); process.exit(1); } } main();