Food Blog Project
A custom food blog for Lisa Lumaca showcasing her recipes and managing them properly
- published
- reading time
- 10 minutes
Building a Custom Food Blog for Lisa Lumaca
In this project, we’ll show you how to create a custom food blog with some really cool features. You’ll be using Hugo as the blog platform, Decap CMS to easily manage content, and adding some JavaScript to make everything more interactive. The goal is to give Lisa Lumaca an easy way to manage her recipes while also allowing users to search for and customize ingredients in the recipes.

Overview
The project includes the following features:
- Custom Blog: Built with Hugo, a fast static site generator.
- Admin Panel: Powered by Decap CMS, enabling easy content management.
- Recipe Management: Allows Lisa to manage recipes with ingredient ratios and quantities that can be adjusted dynamically.
- Search Feature: Implemented search functionality for finding specific recipes.
- Printing Recipe: Implemented a printing feature Iframe.
Key Features
- Custom Hugo Blog: A personalized, fast-loading blog powered by Hugo.
- Decap CMS Integration: A simple-to-use content management system for adding and managing recipes.
- Master Recipe Management: Allows dynamic recipe quantity adjustments based on user input.
- Search Functionality: A search feature to help users quickly find recipes.
- Printing Recipe: A printing feature using Iframe to clean the DOM.
1. Setup and Hugo Blog Initialization
# Install Hugo on your system
brew install hugo
# Create a new Hugo site
hugo new site lisa-lumaca-food-blog
# Initialize a Git repository
git init
# Add a theme
git submodule add https://github.com/budparr/gohugo-theme-ananke.git themes/ananke
Explanation
- Hugo Installation: Hugo is a fast static site generator. You need to install it and initialize a new site.
- Theme Selection: For this tutorial, we are using the
gohugo-theme-ananketheme. You can choose other themes based on your preference.
2. Setting Up Decap CMS for Content Management

# config.yml file for Decap CMS
backend:
name: git-gateway
branch: main
media_folder: "static/images"
public_folder: "/images"
collections:
- name: "recipes"
label: "Recipes"
folder: "content/recipes"
create: true
fields:
- { label: "Title", name: "title", widget: "string" }
- { label: "Ingredients", name: "ingredients", widget: "text" }
- { label: "Instructions", name: "instructions", widget: "text" }
Explanation
- Decap CMS Setup: The CMS configuration file defines how the content (like recipes) is structured and where it will be stored in the repository.
- Media Management: The
media_folderfield specifies the location where images will be stored for easy access.
3. Managing Recipes with Master Recipe and Dynamic Ratios
document.addEventListener("DOMContentLoaded", () => {
const initialRatio = parseInt(
document.getElementById("person-count").value,
10
);
const personCountInput = document.getElementById("person-count");
const personCountLabel = document.getElementById("person-count-label");
const increaseButton = document.getElementById("increase");
const decreaseButton = document.getElementById("decrease");
// Here we will store all the initial quantities for each table
let allIngredientsInitial = [];
// Formatting function for quantities
const formatQuantity = (quantity) => {
return quantity % 1 === 0
? quantity.toFixed(0)
: quantity.toFixed(2).replace(/\.00$/, "");
};
// Function to convert units
const convertUnits = (quantity, unit) => {
const conversions = {
g: { factor: 1000, newUnit: "kg" },
ml: { factor: 1000, newUnit: "L" },
mg: { factor: 1000, newUnit: "g" },
};
if (conversions[unit] && quantity >= conversions[unit].factor) {
quantity /= conversions[unit].factor;
unit = conversions[unit].newUnit;
}
return { quantity, unit };
};
// Function to sort ingredients alphabetically
const sortIngredients = (ingredients) => {
ingredients.sort((a, b) => a.ingredient.localeCompare(b.ingredient));
};
// Function to store the initial quantities of a given table
const getInitialIngredients = (ingredientsTableBody) => {
return Array.from(ingredientsTableBody.querySelectorAll("tr")).map(
(row) => {
const ingredient = row
.querySelector("td:first-child")
.textContent.trim();
const quantityCell = row
.querySelector("td:last-child")
.textContent.trim();
const [quantity, unit] = quantityCell.split(" ");
return {
ingredient,
initialQuantity: parseFloat(quantity),
unit: unit || "",
};
}
);
};
// Function to update the quantities in a table
const updateQuantities = (
newRatio,
initialIngredients,
ingredientsTableBody
) => {
// Recalculate the quantities based on the initial ratio
initialIngredients.forEach((ingredient) => {
let newQuantity = (ingredient.initialQuantity / initialRatio) * newRatio;
let unit = ingredient.unit;
// Convert units if necessary
const converted = convertUnits(newQuantity, unit);
newQuantity = converted.quantity;
unit = converted.unit;
ingredient.formattedQuantity =
formatQuantity(newQuantity) + (unit ? ` ${unit}` : "");
});
// Sort the ingredients alphabetically
sortIngredients(initialIngredients);
// Update the table
ingredientsTableBody.innerHTML = "";
initialIngredients.forEach((ingredient) => {
const row = document.createElement("tr");
row.classList.add("hover:bg-gray-50");
row.innerHTML = `
<td class="py-2 px-4 border-b border-gray-200 text-gray-700">${ingredient.ingredient}</td>
<td class="py-2 px-4 border-b border-gray-200 text-gray-700 ingredient-quantity">${ingredient.formattedQuantity}</td>
`;
ingredientsTableBody.appendChild(row);
});
};
// Handle clicks on the + and - buttons
increaseButton.addEventListener("click", () => {
const newRatio = parseInt(personCountInput.value, 10) + 1;
personCountInput.value = newRatio;
// Apply changes to each ingredients table
allIngredientsInitial.forEach(({ ingredients, ingredientsTableBody }) => {
updateQuantities(newRatio, ingredients, ingredientsTableBody);
});
// Update the display of the person count label
personCountLabel.textContent = `For ${newRatio}`;
});
decreaseButton.addEventListener("click", () => {
let newRatio = parseInt(personCountInput.value, 10) - 1;
if (newRatio < 1) newRatio = 1;
personCountInput.value = newRatio;
// Apply changes to each ingredients table
allIngredientsInitial.forEach(({ ingredients, ingredientsTableBody }) => {
updateQuantities(newRatio, ingredients, ingredientsTableBody);
});
// Update the display of the person count label
personCountLabel.textContent = `For ${newRatio}`;
});
// Initialize each ingredients table by capturing the initial quantities
document
.querySelectorAll(".ingredients-table tbody")
.forEach((ingredientsTableBody) => {
const initialIngredients = getInitialIngredients(ingredientsTableBody);
// Store the table and its initial ingredients for later use
allIngredientsInitial.push({
ingredients: initialIngredients,
ingredientsTableBody: ingredientsTableBody,
});
// Apply the base quantities (initial ratio)
updateQuantities(initialRatio, initialIngredients, ingredientsTableBody);
});
});

label: "Recette",
name: "recipe",
widget: "object",
fields:
[
{ label: "Ratio", name: "ratio", widget: "number" },
{ label: "Type", name: "type", widget: "string" },
{
label: "Master",
name: "master",
widget: "list",
allow_add: true,
fields:
[
{
label: "GroupName",
name: "groupName",
widget: string,
required: false,
},
{
label: "Ingredients",
name: "ingredients",
widget: "list",
allow_add: true,
fields:
[
{
label: "Ingredient",
name: "ingredient",
widget: "string",
},
{
label: "Quantity",
name: "quantity",
widget: number,
value_type: "float",
},
{
label: "Unit",
name: "unit",
widget: "string",
required: false,
},
],
},
],
},
],
Explanation
- Master Recipe Management: This feature allows you to manage recipes dynamically, adjusting ingredient quantities using JavaScript based on user input. You can add custom logic to adjust the ratios of ingredients based on the number of servings or other criteria.
4. Implementing Search Functionality

let pagesIndex, searchIndex;
const MAX_SUMMARY_LENGTH = 200;
const SENTENCE_BOUNDARY_REGEX = /\b\.\s/gm;
const WORD_REGEX = /\b(\w*)[\W|\s|\b]?/gm;
function normalizeString(str) {
return str
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
async function initSearchIndex() {
try {
const response = await fetch("/index.json");
if (response.status !== 200) return;
pagesIndex = await response.json();
searchIndex = lunr(function () {
this.field("title");
this.field("tags");
this.field("content");
this.ref("href");
pagesIndex.forEach((page) => this.add(page));
});
console.log("Search index initialized:", searchIndex);
} catch (e) {
console.error("Error initializing search index:", e);
}
}
function handleSearchQuery(event) {
event.preventDefault();
let query = document.getElementById("search").value.trim().toLowerCase();
query = normalizeString(query); // Normalize search query
if (!query) {
displayErrorMessage("Veuillez saisir un terme de recherche");
return;
}
const results = searchSite(query);
if (!results.length) {
displayErrorMessage("Pas de résultats pour cette recherche");
return;
}
renderSearchResults(query, results);
}
function displayErrorMessage(message) {
const resultsContainer = document.getElementById("search-results");
resultsContainer.innerHTML = `<li>${message}</li>`;
}
function searchSite(query) {
const originalQuery = query;
query = getLunrSearchQuery(query);
let results = getSearchResults(query);
return results.length
? results
: query !== originalQuery
? getSearchResults(originalQuery)
: [];
}
function getLunrSearchQuery(query) {
return query
.split(" ")
.map((term) => `+${term}*`)
.join(" ");
}
function getSearchResults(query) {
if (!searchIndex) return [];
return searchIndex.search(query).map((hit) => {
const page = pagesIndex.find((p) => p.href === hit.ref);
return { ...page, score: hit.score };
});
}
function renderSearchResults(query, results) {
clearSearchResults();
updateSearchResults(query, results);
}
function clearSearchResults() {
const resultsContainer = document.getElementById("search-results");
resultsContainer.innerHTML = "";
}
function updateSearchResults(query, results) {
const resultsContainer = document.getElementById("search-results");
resultsContainer.innerHTML = results
.map(
(hit) => `
<li class="search-result-item" data-score="${hit.score.toFixed(2)}">
<a href="${hit.href}" class="search-result-page-title">${hit.title}</a>
<p>${createSearchResultBlurb(query, hit.content)}</p>
</li>`
)
.join("");
document.querySelectorAll(".search-result-item").forEach((li) => {
li.querySelector("a").style.color = getColorForSearchResult(
li.dataset.score
);
});
}
function createSearchResultBlurb(query, pageContent) {
const searchQueryRegex = new RegExp(createQueryStringRegex(query), "gmi");
const searchQueryHits = [...pageContent.matchAll(searchQueryRegex)].map(
(m) => m.index
);
const sentenceBoundaries = [
...pageContent.matchAll(SENTENCE_BOUNDARY_REGEX),
].map((m) => m.index);
let searchResultText = "";
let lastEndOfSentence = 0;
for (const hitLocation of searchQueryHits) {
if (hitLocation > lastEndOfSentence) {
for (let i = 0; i < sentenceBoundaries.length; i++) {
if (sentenceBoundaries[i] > hitLocation) {
const startOfSentence = i > 0 ? sentenceBoundaries[i - 1] + 1 : 0;
const endOfSentence = sentenceBoundaries[i];
lastEndOfSentence = endOfSentence;
const parsedSentence = pageContent
.slice(startOfSentence, endOfSentence)
.trim();
searchResultText += `${parsedSentence} ... `;
break;
}
}
}
if (tokenize(searchResultText).length >= MAX_SUMMARY_LENGTH) break;
}
return ellipsize(searchResultText, MAX_SUMMARY_LENGTH).replace(
searchQueryRegex,
"<strong>$&</strong>"
);
}
function createQueryStringRegex(query) {
const escaped = query.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
return `(${escaped.split(" ").join("|")})`;
}
function tokenize(input) {
return [...input.matchAll(WORD_REGEX)].map((m) => ({
word: m[0],
start: m.index,
end: m.index + m[0].length,
length: m[0].length,
}));
}
function ellipsize(input, maxLength) {
const words = tokenize(input);
return words.length <= maxLength
? input
: input.slice(0, words[maxLength].end) + "...";
}
function getColorForSearchResult(score) {
const warmColorHue = 200;
const coolColorHue = 250;
return adjustHue(warmColorHue, coolColorHue, score);
}
function adjustHue(hue1, hue2, score) {
const hueAdjust = Math.min(score / 3, 1) * (hue1 - hue2);
return `hsl(${hue2 + hueAdjust}, 100%, 75%)`;
}
document.addEventListener("DOMContentLoaded", function () {
initSearchIndex();
const searchInput = document.getElementById("search");
searchInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
handleSearchQuery(event);
} else if (searchInput.value.length >= 2) {
clearTimeout(searchInput.searchTimeout);
searchInput.searchTimeout = setTimeout(() => {
handleSearchQuery(event);
}, 300);
}
});
document
.querySelector(".fa-search")
.addEventListener("click", (event) => handleSearchQuery(event));
});
Explanation
- Search Integration: A search feature helps users find specific recipes by their title, ingredients, or other metadata. You can integrate a search script to filter the content of your blog based on keywords.
5. Printing Recipes
const printRecipe = () => {
// Select the elements to print
const title = document.getElementById("title").outerHTML;
const lead = document.getElementById("lead").outerHTML;
const featureImage = document.getElementById("featureimage").outerHTML;
const recipeSection = document.getElementById("recipe-section").outerHTML;
const content = document.getElementById("content").cloneNode(true);
// Remove all additional images from the content
const images = content.querySelectorAll("img");
images.forEach((img) => img.remove());
// Create the content to print
const printContent = `
<div>
${title}
${lead}
${featureImage}
${recipeSection}
${content.innerHTML}
</div>
`;
// Create a hidden iframe for printing
const printFrame = document.createElement("iframe");
printFrame.style.position = "absolute";
printFrame.style.width = "0";
printFrame.style.height = "0";
printFrame.style.border = "none";
document.body.appendChild(printFrame);
// Add the content to print inside the iframe
const doc = printFrame.contentDocument || printFrame.contentWindow.document;
doc.open();
doc.write(`
<html>
<head>
<title>Print Recipe</title>
<style>
/* Styles for printing */
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { font-size: 2em; margin-bottom: 20px; }
img { max-width: 100%; height: auto; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.ingredients-table { margin-bottom: 20px; }
p { margin: 10px 0; }
/* Reduce the size of the main banner image */
#featureimage img {
width: 100%;
height: auto;
max-height: 200px; /* Limits height to create a banner */
object-fit: cover;
}
</style>
</head>
<body>
${printContent}
</body>
</html>
`);
doc.close();
// Trigger print dialog
printFrame.contentWindow.focus();
printFrame.contentWindow.print();
// Remove the iframe after printing to clean up the DOM
printFrame.addEventListener("afterprint", () => {
printFrame.remove();
});
};

Explanation
- Printing feature: This feature is used to create a printable version of a recipe webpage by gathering specific sections, removing unnecessary elements (like additional images), and formatting the content in a printable format. It uses an iframe to render the content and initiate the print dialog, allowing the user to easily print the recipe. After the print action is completed, the iframe is removed to clean up the DOM.
6. Frontend Layout and Design
<!-- Basic HTML structure for the blog homepage -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Site.Title }}</title>
</head>
<body>
<header>
<h1>{{ .Site.Title }}</h1>
</header>
<main>
{{ range .Pages }}
<article>
<h2>{{ .Title }}</h2>
<p>{{ .Summary }}</p>
<a href="{{ .Permalink }}">Read more</a>
</article>
{{ end }}
</main>
<footer>
<p>© 2025 Lisa Lumaca's Food Blog</p>
</footer>
</body>
</html>
Explanation
- Frontend Layout: This is a basic structure for the blog homepage, displaying a list of blog posts (recipes) with titles, summaries, and links to full pages.
- Dynamic Content: Hugo uses Go templates to dynamically render content, such as recipe titles and summaries.
7. Hosting the Blog and CMS
Once you’ve got everything set up and looking good, it’s time to take it live! You can easily deploy your site using platforms like Netlify for smooth hosting and automatic updates. For managing your blog content, I’ve set up Decap CMS, which allows the blog owner to easily publish new posts and articles. In this project, I’m using Git-Gateway with Netlify to make the publishing process seamless for the owner.
8. Conclusion
And that’s it! By following this guide, you’ve created a personalized food blog for Lisa Lumaca, using Hugo as the foundation for the blog platform, Decap CMS to manage content effortlessly, and some dynamic JavaScript to handle recipes. The beauty of this project is that it’s super flexible—there’s room for future upgrades like adding a commenting system, user logins, and more.