How the Cookbook Code Works
This cookbook began as a working demonstration of XML, XSL and DTD. That educational idea is still useful: the recipe data is kept separate from the rules that define it and the stylesheet that displays it.
The three core pieces
1. A real XML recipe
This is the current Lefse version 1 file. Each recipe links to the DTD and XSLT by relative path, so the cookbook can live in the lowercase /cookbook/ directory without hard-coded domain paths.
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE recipe SYSTEM "../../dtd/recipe.dtd"> <?xml-stylesheet type="text/xsl" href="../../style/recipe.xsl" ?> <recipe> <title>Lefse version 1</title> <cook>Norwegians</cook> <category>Potato flat bread</category> <description>My favorite other thing, made by my late Norwegian grandmother. This recipe is similar to the ones .</description> <prep_time>Two days</prep_time> <illustration href="../../images/lefse1.jpg" /> <ingredients> <item>3 large baking potatoes</item> <item>2 T butter</item> <item>1/4 cup (1/2 dl) heavy cream</item> <item>1/2 tsp. sugar</item> <item>1 tsp. salt </item> <item>1 cup (2 1/2 dl) flour</item> </ingredients> <directions> <step>Boil the potatoes without peeling, peel and mash while still warm and put through ricer.</step> <step>Whisk in cream.</step> <step>. To get the lumps out, you might have to put them through ricer more than once.</step> <step>Add the remaining ingredients, mix well, cover and chill for 8 hours or overnight.</step> <step>Mix in 1/2 cup flour. Divide into 15-16 balls if you want dinner-plate size.</step> <step>Using a grooved rolling pin with sock, (sock optional), and pastry canvas, roll each ball out as thin as possible. use flour as needed (but not too much), and keep balance of dough in the refrigerator.</step> <step>Bake each lefse on medium to hot griddle, turning until both sides are flecked with brown.</step> <step>Do not overbake. </step> </directions> <yield>Not enough.</yield> <comment> Grandmother (Marie Olsen) would butter then sprinkle sugrar over them before rolling them up and eating them like a finger food. Me too, one right after another and as a child pretending they were big cigars.</comment> </recipe>
2. The current DTD — dtd/recipe.dtd
The DTD establishes the expected order and allowed elements for each recipe.
<!ELEMENT recipe (title, cook, category, description, prep_time, illustration, ingredients, directions, yield, comment)> <!ELEMENT title (#PCDATA)> <!ELEMENT cook (#PCDATA)> <!ELEMENT category (#PCDATA)> <!ELEMENT prep_time (#PCDATA)> <!ELEMENT description (#PCDATA)> <!ELEMENT ingredients (item+)> <!ELEMENT item (#PCDATA)> <!ELEMENT ingredient (#PCDATA)> <!ELEMENT directions (step*)> <!ELEMENT step (#PCDATA)> <!ELEMENT yield (#PCDATA)> <!ELEMENT comment (#PCDATA)> <!ELEMENT illustration EMPTY> <!ATTLIST illustration href CDATA #REQUIRED>
3. The current XSLT — style/recipe.xsl
The stylesheet uses standard XSLT 1.0 and transforms each recipe into responsive HTML. The same XSLT file presents every XML recipe.
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="html" encoding="UTF-8" indent="yes" />
<xsl:strip-space elements="*" />
<xsl:template match="/recipe">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><xsl:value-of select="title" /> — Trillium University Cookbook</title>
<link rel="stylesheet" href="../../style/recipe.css" />
</head>
<body>
<div class="site-shell">
<main class="paper">
<header class="site-header">
<a class="cookbook-logo-link" href="../../index.php"><img class="cookbook-logo-small" src="../../images/cookbooklogo05.png" alt="Trillium University Cookbook" /></a>
<div class="navbar">
<a class="menu" href="../../index.php">Cookbook Home</a>
<a class="menu" href="../../recipeindex.php">Recipe Index</a>
<a class="menu" href="../../access.php">Contribute a Recipe</a>
<a class="menu" href="../../code.php">How the Code Works</a>
</div>
</header>
<div class="recipe-meta">
<div><strong>Category:</strong> <xsl:value-of select="category" /></div>
<div><strong>Yield:</strong> <xsl:value-of select="yield" /></div>
<div><strong>Preparation time:</strong> <xsl:value-of select="prep_time" /></div>
</div>
<h1 class="recipe-title-main"><xsl:value-of select="title" /></h1>
<xsl:if test="normalize-space(cook) != ''">
<h2 class="recipe-cook">Cook: <xsl:value-of select="cook" /></h2>
</xsl:if>
<xsl:if test="normalize-space(description) != ''">
<p class="recipe-description"><xsl:value-of select="description" /></p>
</xsl:if>
<div class="recipe-layout">
<section>
<h2>Ingredients</h2>
<ul class="ingredients">
<xsl:for-each select="ingredients/item">
<li><xsl:value-of select="." /></li>
</xsl:for-each>
</ul>
</section>
<xsl:if test="normalize-space(illustration/@href) != ''">
<div class="recipe-image">
<img alt="Recipe illustration">
<xsl:attribute name="src"><xsl:value-of select="illustration/@href" /></xsl:attribute>
</img>
</div>
</xsl:if>
</div>
<section>
<h2>Directions</h2>
<ol class="directions">
<xsl:for-each select="directions/step">
<li><xsl:value-of select="." /></li>
</xsl:for-each>
</ol>
</section>
<xsl:if test="normalize-space(comment) != ''">
<div class="comments"><strong>Comments:</strong> <xsl:value-of select="comment" /></div>
</xsl:if>
<div class="actions">
<button type="button" onclick="window.print()">Print Recipe</button>
<a class="button" href="../../recipeindex.php">Back to Recipe Index</a>
</div>
<footer class="site-footer">
<img class="tu-mark" src="../../images/tu.gif" alt="Trillium University" />
<p>School of Culinary Arts Recipe Book — an XML, XSLT and DTD teaching project from FJD1.com.</p>
</footer>
</main>
</div>
<div class="cookbook-music-player" data-cookbook-music="">
<div class="cookbook-music-title">♫ Mushroom Pickin' Blues</div>
<div class="cookbook-music-credit">Frank J. Davies · recorded by Tim Prelwitz</div>
<div class="cookbook-music-controls">
<button class="cookbook-music-toggle" type="button" aria-pressed="false">Play</button>
<label class="cookbook-music-volume-label">
<span>Volume</span>
<input class="cookbook-music-volume" type="range" min="0" max="1" step="0.05" value="0.28" aria-label="Music volume" />
</label>
</div>
<audio class="cookbook-music-audio" preload="metadata" loop="loop" src="../../includes/mushroompickinblues-tim.mp3"></audio>
</div>
<script src="../../script/cookbook-music.js"></script>
</body>
</html>
</xsl:template>
</xsl:stylesheet>
4. The Recipe Writer JavaScript and safe server save
The Recipe Writer uses JavaScript to build the same XML structure in the browser for preview, copying, and download. When an authorized contributor presses Save Recipe to Cookbook, the form is sent to save-recipe.php. PHP validates the fields, creates the XML on the server, and can also save one optional JPG, PNG, GIF, or WebP recipe photograph. Contributors cannot upload arbitrary XML, PHP, JavaScript, or other executable files.
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
const form = $('recipe-form');
const ingredients = $('ingredients');
const directions = $('directions');
const output = $('xml-output');
const filename = $('filename');
const folderHint = $('folder-hint');
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function slug(value) {
const cleaned = String(value || '')
.toLowerCase()
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return (cleaned || 'recipe') + '.xml';
}
function addRow(container, multiline = false, value = '') {
const row = document.createElement('div');
row.className = 'dynamic-row';
const input = multiline ? document.createElement('textarea') : document.createElement('input');
input.className = multiline ? 'step-input' : 'ingredient-input';
input.name = multiline ? 'steps[]' : 'ingredients[]';
input.value = value;
if (multiline) input.rows = 3;
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = 'Remove';
remove.addEventListener('click', () => row.remove());
row.append(input, remove);
container.append(row);
input.focus();
}
function values(selector) {
return [...document.querySelectorAll(selector)].map(el => el.value.trim()).filter(Boolean);
}
function validateBasics(showAlert = true) {
if (!$('title').value.trim()) {
if (showAlert) alert('Please enter a recipe title.');
$('title').focus();
return false;
}
if (!values('.ingredient-input').length) {
if (showAlert) alert('Please enter at least one ingredient.');
const first = document.querySelector('.ingredient-input');
if (first) first.focus();
return false;
}
return true;
}
function makeXml() {
if (!validateBasics()) return '';
const title = $('title').value.trim();
const ingredientValues = values('.ingredient-input');
const stepValues = values('.step-input');
const categoryFolder = $('category-folder').value;
const categoryLabel = $('category-label').value.trim() || categoryFolder.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE recipe SYSTEM="../../dtd/recipe.dtd">',
'<?xml-stylesheet type="text/xsl" href="../../style/recipe.xsl" ?>',
'<!-- Created with the Trillium University / FJD1 Recipe Writer -->',
'',
'<recipe>',
` <title>${escapeXml(title)}</title>`,
` <cook>${escapeXml($('cook').value.trim())}</cook>`,
` <category>${escapeXml(categoryLabel)}</category>`,
` <description>${escapeXml($('description').value.trim())}</description>`,
` <prep_time>${escapeXml($('prep-time').value.trim())}</prep_time>`,
` <illustration href="${escapeXml($('illustration').value.trim())}" />`,
' <ingredients>',
...ingredientValues.map(v => ` <item>${escapeXml(v)}</item>`),
' </ingredients>',
' <directions>',
...stepValues.map(v => ` <step>${escapeXml(v)}</step>`),
' </directions>',
` <yield>${escapeXml($('yield').value.trim())}</yield>`,
` <comment>${escapeXml($('comment').value.trim())}</comment>`,
'</recipe>',
''
];
const xml = lines.join('\n');
output.value = xml;
filename.value = slug(title);
folderHint.textContent = `Server destination: recipes/${categoryFolder}/${filename.value}`;
return xml;
}
$('add-ingredient').addEventListener('click', () => addRow(ingredients, false));
$('add-step').addEventListener('click', () => addRow(directions, true));
$('generate').addEventListener('click', makeXml);
$('title').addEventListener('input', () => { filename.value = slug($('title').value); });
$('category-folder').addEventListener('change', () => {
folderHint.textContent = `Server destination: recipes/${$('category-folder').value}/${filename.value || 'recipe.xml'}`;
});
form.addEventListener('submit', (event) => {
if (!validateBasics()) event.preventDefault();
});
$('copy').addEventListener('click', async () => {
const xml = output.value || makeXml();
if (!xml) return;
try {
await navigator.clipboard.writeText(xml);
$('copy').textContent = 'Copied!';
setTimeout(() => $('copy').textContent = 'Copy XML', 1400);
} catch (_) {
output.focus();
output.select();
document.execCommand('copy');
}
});
$('download').addEventListener('click', () => {
const xml = output.value || makeXml();
if (!xml) return;
const blob = new Blob([xml], {type: 'application/xml;charset=utf-8'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename.value || slug($('title').value);
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
});
addRow(ingredients, false);
addRow(ingredients, false);
addRow(directions, true);
addRow(directions, true);
filename.value = 'recipe.xml';
folderHint.textContent = `Server destination: recipes/${$('category-folder').value}/recipe.xml`;
})();
Why XML here?
A recipe is naturally structured data: title, cook, category, description, preparation time, ingredients, directions, yield and comments. XML makes those parts explicit. The same data can be displayed differently later without rewriting the recipe itself.

