Initial commit — Bar Pivoine cocktail recipe site
Hugo Extended site with 426 cocktail recipes from the open cocktail dataset. Dark amber/gold editorial aesthetic, Tailwind CSS v4, Alpine.js client-side search and filtering, HTMX page transitions, Docker + nginx production build. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* generate-content.mjs
|
||||
* Reads prototype/uploads/final_cocktails.csv and creates Hugo content bundles
|
||||
* under content/recipes/{slug}/index.md
|
||||
*
|
||||
* Usage: node scripts/generate-content.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { parse } from "csv-parse/sync";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..");
|
||||
const CSV_PATH = join(ROOT, "prototype/uploads/final_cocktails.csv");
|
||||
const CONTENT_DIR = join(ROOT, "content/recipes");
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function slugify(name) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/&/g, "and")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Parse a Python-style list string: "['Gin', 'Grand Marnier', None]" → ["Gin", "Grand Marnier"] */
|
||||
function parsePythonList(str) {
|
||||
if (!str || str.trim() === "" || str === "None") return [];
|
||||
try {
|
||||
const normalized = str
|
||||
.replace(/None/g, "null")
|
||||
.replace(/'/g, '"');
|
||||
const arr = JSON.parse(normalized);
|
||||
return arr.filter((v) => v !== null && v !== undefined && String(v).trim() !== "");
|
||||
} catch {
|
||||
// Fallback: extract quoted strings manually
|
||||
const matches = str.match(/'([^']+)'/g);
|
||||
if (matches) return matches.map((m) => m.replace(/'/g, "").trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape a YAML string value, wrapping in double quotes and escaping internals */
|
||||
function yamlString(val) {
|
||||
if (!val) return '""';
|
||||
const escaped = String(val).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
/** Build a YAML sequence from a JS array of strings */
|
||||
function yamlList(arr) {
|
||||
if (!arr || arr.length === 0) return "[]";
|
||||
return `[${arr.map((v) => yamlString(v)).join(", ")}]`;
|
||||
}
|
||||
|
||||
/** Generate a SEO description from cocktail fields */
|
||||
function buildDescription(name, category, glass, ingredients) {
|
||||
const ingredientSummary =
|
||||
ingredients.length > 0
|
||||
? `Made with ${ingredients.slice(0, 4).join(", ")}${ingredients.length > 4 ? " and more" : ""}.`
|
||||
: "";
|
||||
return `${name}: a ${category} served in a ${glass}. ${ingredientSummary}`.trim();
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const csv = readFileSync(CSV_PATH, "utf-8");
|
||||
const records = parse(csv, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
relax_column_count: true,
|
||||
trim: true,
|
||||
});
|
||||
|
||||
console.log(`Parsed ${records.length} cocktails from CSV`);
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
const slugMap = new Map(); // track duplicates
|
||||
|
||||
for (const row of records) {
|
||||
const name = (row.name || "").trim();
|
||||
if (!name) continue;
|
||||
|
||||
let slug = slugify(name);
|
||||
// Handle duplicate slugs by appending the id
|
||||
if (slugMap.has(slug)) {
|
||||
slug = `${slug}-${row.id || slugMap.size}`;
|
||||
}
|
||||
slugMap.set(slug, true);
|
||||
|
||||
const dir = join(CONTENT_DIR, slug);
|
||||
const file = join(dir, "index.md");
|
||||
|
||||
if (existsSync(file)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ingredients = parsePythonList(row.ingredients);
|
||||
const measures = parsePythonList(row.ingredientMeasures);
|
||||
const category = (row.category || "Cocktail").trim();
|
||||
const glass = (row.glassType || "Cocktail glass").trim();
|
||||
const alcoholic = (row.alcoholic || "").trim();
|
||||
const thumbnail = (row.drinkThumbnail || "").trim();
|
||||
const instructions = (row.instructions || "").trim();
|
||||
const description = buildDescription(name, category, glass, ingredients);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const frontmatter = [
|
||||
"---",
|
||||
`title: ${yamlString(name)}`,
|
||||
`date: "2026-01-01"`,
|
||||
`draft: false`,
|
||||
`description: ${yamlString(description)}`,
|
||||
`alcoholic: ${yamlString(alcoholic)}`,
|
||||
`categories: ${yamlList([category])}`,
|
||||
`glasses: ${yamlList([glass])}`,
|
||||
`ingredients: ${yamlList(ingredients)}`,
|
||||
`ingredientMeasures: ${yamlList(measures)}`,
|
||||
`drinkThumbnail: ${yamlString(thumbnail)}`,
|
||||
"---",
|
||||
"",
|
||||
instructions,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
writeFileSync(file, frontmatter, "utf-8");
|
||||
created++;
|
||||
|
||||
if (created % 50 === 0) {
|
||||
process.stdout.write(` [${created}/${records.length}] created…\r`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. Created: ${created}, Skipped (already exist): ${skipped}`);
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* generate-images.mjs
|
||||
* Generates AI cocktail images via Replicate flux-2-pro and saves them as
|
||||
* content/recipes/{slug}/cocktail.webp
|
||||
*
|
||||
* Usage:
|
||||
* REPLICATE_API_TOKEN=r8_... node scripts/generate-images.mjs
|
||||
* REPLICATE_API_TOKEN=r8_... node scripts/generate-images.mjs --limit 10
|
||||
* REPLICATE_API_TOKEN=r8_... node scripts/generate-images.mjs --slug margarita
|
||||
*
|
||||
* Options:
|
||||
* --limit N Only process the first N cocktails
|
||||
* --slug NAME Process only the cocktail with this slug
|
||||
* --concurrency N Parallel requests (default: 3)
|
||||
* --dry-run Log what would be generated without calling API
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { parse } from "csv-parse/sync";
|
||||
import Replicate from "replicate";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..");
|
||||
const CSV_PATH = join(ROOT, "prototype/uploads/final_cocktails.csv");
|
||||
const CONTENT_DIR = join(ROOT, "content/recipes");
|
||||
|
||||
const PROMPT = `A high-end, professional commercial photograph of the cocktail from the reference image. The drink retains its original vibrant color hue, served in the exact same glass type with identical decorations details. The liquid has a crisp, realistic texture with subtle condensation droplets on the outside of the glassware. The cocktail is set against a modern, moody, and minimalist bar background with soft, cinematic bokeh. Elegant, dramatic studio lighting casts a gentle glow on the glass, creating sharp reflections and a premium, editorial aesthetic fit for a luxury cocktail book. Photorealistic, 8k resolution, crisp focus.`;
|
||||
|
||||
// ── CLI args ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const limitIdx = args.indexOf("--limit");
|
||||
const LIMIT = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : Infinity;
|
||||
const slugIdx = args.indexOf("--slug");
|
||||
const ONLY_SLUG = slugIdx !== -1 ? args[slugIdx + 1] : null;
|
||||
const concurrencyIdx = args.indexOf("--concurrency");
|
||||
const CONCURRENCY = concurrencyIdx !== -1 ? parseInt(args[concurrencyIdx + 1], 10) : 3;
|
||||
const DRY_RUN = args.includes("--dry-run");
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function slugify(name) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/&/g, "and")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
async function downloadToBuffer(url) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
|
||||
const ab = await res.arrayBuffer();
|
||||
return Buffer.from(ab);
|
||||
}
|
||||
|
||||
// ── Replicate call ────────────────────────────────────────────────────────────
|
||||
|
||||
async function generateImage(replicate, cocktail, index, total) {
|
||||
const { slug, name, thumbnail } = cocktail;
|
||||
const outPath = join(CONTENT_DIR, slug, "cocktail.webp");
|
||||
|
||||
if (existsSync(outPath)) {
|
||||
console.log(`[${index}/${total}] SKIP ${name} (image exists)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(join(CONTENT_DIR, slug))) {
|
||||
console.warn(`[${index}/${total}] WARN ${name}: content dir missing, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(`[${index}/${total}] DRY ${name} → ${outPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[${index}/${total}] GEN ${name}…`);
|
||||
|
||||
try {
|
||||
const input = {
|
||||
prompt: PROMPT,
|
||||
output_format: "webp",
|
||||
aspect_ratio: "1:1",
|
||||
safety_tolerance: 6,
|
||||
};
|
||||
|
||||
// Add reference image if thumbnail URL exists
|
||||
if (thumbnail) {
|
||||
input.image = thumbnail;
|
||||
input.image_prompt_strength = 0.15;
|
||||
}
|
||||
|
||||
const output = await replicate.run("black-forest-labs/flux-2-pro", { input });
|
||||
|
||||
// Output is either a URL string or a ReadableStream
|
||||
let imageData;
|
||||
if (typeof output === "string") {
|
||||
imageData = await downloadToBuffer(output);
|
||||
} else if (Array.isArray(output) && output.length > 0) {
|
||||
const first = output[0];
|
||||
if (typeof first === "string") {
|
||||
imageData = await downloadToBuffer(first);
|
||||
} else {
|
||||
// Replicate stream
|
||||
const chunks = [];
|
||||
for await (const chunk of first) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
imageData = Buffer.concat(chunks);
|
||||
}
|
||||
} else if (output && typeof output[Symbol.asyncIterator] === "function") {
|
||||
const chunks = [];
|
||||
for await (const chunk of output) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
imageData = Buffer.concat(chunks);
|
||||
} else {
|
||||
throw new Error(`Unexpected output type: ${typeof output}`);
|
||||
}
|
||||
|
||||
writeFileSync(outPath, imageData);
|
||||
console.log(`[${index}/${total}] DONE ${name} → ${imageData.length} bytes`);
|
||||
} catch (err) {
|
||||
console.error(`[${index}/${total}] ERR ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Concurrency limiter ───────────────────────────────────────────────────────
|
||||
|
||||
async function runWithConcurrency(tasks, concurrency) {
|
||||
const results = [];
|
||||
let idx = 0;
|
||||
|
||||
async function worker() {
|
||||
while (idx < tasks.length) {
|
||||
const current = idx++;
|
||||
await tasks[current]();
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, worker);
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const apiToken = process.env.REPLICATE_API_TOKEN;
|
||||
if (!apiToken && !DRY_RUN) {
|
||||
console.error("Error: REPLICATE_API_TOKEN environment variable is not set.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const replicate = new Replicate({ auth: apiToken });
|
||||
|
||||
const csv = readFileSync(CSV_PATH, "utf-8");
|
||||
const records = parse(csv, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
relax_column_count: true,
|
||||
trim: true,
|
||||
});
|
||||
|
||||
// Build cocktail list
|
||||
const slugMap = new Map();
|
||||
const cocktails = [];
|
||||
|
||||
for (const row of records) {
|
||||
const name = (row.name || "").trim();
|
||||
if (!name) continue;
|
||||
|
||||
let slug = slugify(name);
|
||||
if (slugMap.has(slug)) {
|
||||
slug = `${slug}-${row.id || slugMap.size}`;
|
||||
}
|
||||
slugMap.set(slug, true);
|
||||
|
||||
if (ONLY_SLUG && slug !== ONLY_SLUG) continue;
|
||||
|
||||
cocktails.push({
|
||||
slug,
|
||||
name,
|
||||
thumbnail: (row.drinkThumbnail || "").trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const limited = isFinite(LIMIT) ? cocktails.slice(0, LIMIT) : cocktails;
|
||||
console.log(`Processing ${limited.length} cocktails (concurrency: ${CONCURRENCY})${DRY_RUN ? " [DRY RUN]" : ""}`);
|
||||
|
||||
const tasks = limited.map((cocktail, i) => () =>
|
||||
generateImage(replicate, cocktail, i + 1, limited.length)
|
||||
);
|
||||
|
||||
await runWithConcurrency(tasks, CONCURRENCY);
|
||||
console.log("\nAll done.");
|
||||
Reference in New Issue
Block a user