burtenshaw
feat: publish slopfarmer article
3878dd8
#!/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";
<Introduction />
`;
}
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/<your-username>/<your-space>
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/<user>/${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/<user>/${name} && git push space main`,
),
);
}
console.log("");
} catch (err) {
prompt.close();
console.error(fmt.red(`\n Error: ${err.message}\n`));
process.exit(1);
}
}
main();