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}`);
|
||||
Reference in New Issue
Block a user