cookcli 0.36.0

A command-line interface for managing and working with Cooklang recipes
Documentation
{% extends "base.html" %}

{% block title %}{{ name }} - Cook{% endblock %}

{% block content %}
<div id="menu-content">
    <nav class="mb-4 print:hidden">
        <ol class="flex items-center space-x-2 text-sm text-muted">
            <li><a href="{{ prefix }}/{% if static_mode %}index.html{% endif %}" class="hover:text-text">{{ tr.t("nav-recipes") }}</a></li>
            {% for crumb in breadcrumbs %}
            <li class="flex items-center">
                <span class="mx-2 text-faint">/</span>
                {% if loop.last %}
                    <span class="text-text">{{ crumb }}</span>
                {% else %}
                    <a href="{{ prefix }}/directory/{{ breadcrumbs[..loop.index0 + 1].join("/") }}{% if static_mode %}.html{% endif %}" class="hover:text-text">{{ crumb }}</a>
                {% endif %}
            </li>
            {% endfor %}
        </ol>
    </nav>

    <div class="mb-4">
        <!-- Menu image if available -->
        {% match image_path %}
        {% when Some with (img) %}
        <div class="mb-4 max-w-4xl mx-auto print:hidden">
            <div class="overflow-hidden card bg-sunk">
                <img src="{{ img }}" alt="{{ name }}" class="w-full h-auto max-h-[500px] object-contain mx-auto">
            </div>
        </div>
        {% when None %}
        {% endmatch %}

        <!-- Title bar with scale and shopping list button -->
        <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4 print:mb-1">
            <div class="flex items-center gap-3">
                <h1 class="text-display font-bold text-text print:text-2xl">{{ name }}</h1>
                <span class="tag print:hidden">{{ tr.t("recipe-type-menu") }}</span>
            </div>
            {% if !static_mode %}
            <div class="flex flex-wrap items-center gap-2 lg:gap-3 print:hidden">
                <div class="flex items-center">
                    <label for="scale" class="text-sm font-medium text-muted mr-2">{{ tr.t("recipe-scale-label") }}:</label>
                    <div class="stepper">
                        <button type="button" aria-label="{{ tr.t("aria-decrease-scale") }}" onclick="adjustScale(-0.5)">&minus;</button>
                        <input type="number"
                               id="scale"
                               value="{{ scale }}"
                               min="0.5"
                               max="200"
                               step="0.5"
                               onchange="goToScale(this.value)">
                        <button type="button" aria-label="{{ tr.t("aria-increase-scale") }}" onclick="adjustScale(0.5)">+</button>
                    </div>
                </div>
                <a href="{{ prefix }}/edit/{{ recipe_path }}" class="btn" title="{{ tr.t("action-edit") }}">
                    <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
                    </svg>
                    <span>{{ tr.t("action-edit") }}</span>
                </a>
                <button onclick="addToShoppingList(event, {{ recipe_path|json }})" class="btn btn-primary" title="{{ tr.t("recipe-add-all-to-shopping") }}">
                    <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path>
                    </svg>
                    <span>{{ tr.t("recipe-add-all-to-shopping") }}</span>
                </button>
            </div>
            {% endif %}
        </div>

        <!-- Menu info section -->
        <div class="space-y-2 print:hidden">
            {% match metadata %}
            {% when Some with (metadata) %}

            <!-- Description if present -->
            {% match metadata.description %}
            {% when Some with (description) %}
            <div class="recipe-note">
                <p class="m-0">{{ description }}</p>
            </div>
            {% when None %}
            {% endmatch %}

            <!-- Metadata row -->
            <div id="metadata-container" class="flex flex-wrap gap-3">
                {% match metadata.servings %}
                {% when Some with (servings) %}
                <span class="metadata-pill metadata-servings">👥 {{ servings }} {{ tr.t("recipe-servings-label") }}</span>
                {% when None %}
                {% endmatch %}

                {% match metadata.time %}
                {% when Some with (time) %}
                <span class="metadata-pill metadata-time">⏱️ {{ time }}</span>
                {% when None %}
                {% endmatch %}

                {% match metadata.author %}
                {% when Some with (author) %}
                <span class="metadata-pill metadata-author">👤 {{ author }}</span>
                {% when None %}
                {% endmatch %}

                {% match metadata.source %}
                {% when Some with (source) %}
                <span class="metadata-pill metadata-source">📖 {{ source }}</span>
                {% when None %}
                {% endmatch %}

                {% match metadata.source_url %}
                {% when Some with (source_url) %}
                <span class="metadata-pill metadata-source-url">🔗 <a href="{{ source_url }}" class="text-info hover:underline ml-1">{{ source_url|hostname }}</a></span>
                {% when None %}
                {% endmatch %}

                <!-- Custom metadata -->
                {% for (key, value) in metadata.custom %}
                <span class="metadata-pill metadata-custom">{{ key }}: {{ value }}</span>
                {% endfor %}
            </div>

            {% when None %}
            {% endmatch %}
        </div>
    </div>

    <!-- Menu sections -->
    <div class="space-y-6">
            {% for section in sections %}
            <div class="menu-section card overflow-hidden">
                {% match section.name %}
                {% when Some with (name) %}
                <div class="menu-section-header bg-sunk border-b border-line px-6 py-3">
                    <h2 class="text-title font-semibold text-text">{{ name }}</h2>
                </div>
                {% when None %}
                {% endmatch %}

                <div class="menu-section-body p-6 space-y-2">
                    {% for line in section.lines %}
                    {% if line.len() == 1 %}
                        {% match line[0] %}
                        {% when crate::web::templates::MenuSectionItem::Text with (text) %}
                            {% if text.trim().ends_with(":") %}
                            <!-- Meal type header -->
                            <h3 class="text-title font-semibold text-text mt-2 mb-1">{{ text }}</h3>
                            {% else %}
                            <!-- Regular text line -->
                            <div class="pl-6">
                                <span class="text-text">{{ text }}</span>
                            </div>
                            {% endif %}
                        {% when _ %}
                            <!-- Single non-text item -->
                            <div class="pl-6 flex items-baseline gap-x-2">
                                {% match line[0] %}
                                {% when crate::web::templates::MenuSectionItem::RecipeReference with { name, scale } %}
                                    <span class="inline-flex items-center gap-1">
                                        <a href="{{ prefix }}/recipe/{{ name.strip_prefix("./").unwrap_or(name) }}{% if static_mode %}.html{% endif %}"
                                           class="text-info font-medium hover:underline">
                                            {{ name.strip_prefix("./").unwrap_or(name).replace("/", " › ") }}
                                        </a>
                                        {% match scale %}
                                        {% when Some with (s) %}
                                            <span class="text-sm text-faint">(×{{ s }})</span>
                                        {% when None %}
                                        {% endmatch %}
                                    </span>
                                {% when crate::web::templates::MenuSectionItem::Ingredient with { name, quantity, unit } %}
                                    <span class="inline-flex items-center gap-1">
                                        <span class="ingredient-badge">
                                            {{ name }}
                                            {% match quantity %}
                                            {% when Some with (q) %}
                                                <span>{{ q }}</span>
                                            {% when None %}
                                            {% endmatch %}
                                            {% match unit %}
                                            {% when Some with (u) %}
                                                <span class="font-normal text-muted">{{ u }}</span>
                                            {% when None %}
                                            {% endmatch %}
                                        </span>
                                    </span>
                                {% when _ %}
                                {% endmatch %}
                            </div>
                        {% endmatch %}
                    {% else %}
                    <!-- Multi-item line (meal item with ingredients) -->
                    <div class="pl-6 flex flex-wrap items-baseline gap-x-2">
                        {% for item in line %}
                            {% match item %}
                            {% when crate::web::templates::MenuSectionItem::Text with (text) %}
                                <span class="text-text">{{ text }}</span>

                            {% when crate::web::templates::MenuSectionItem::RecipeReference with { name, scale } %}
                                <span class="inline-flex items-center gap-1">
                                    <a href="{{ prefix }}/recipe/{{ name.strip_prefix("./").unwrap_or(name) }}{% if static_mode %}.html{% endif %}"
                                       class="text-info font-medium hover:underline">
                                        {{ name.strip_prefix("./").unwrap_or(name).replace("/", " › ") }}
                                    </a>
                                    {% match scale %}
                                    {% when Some with (s) %}
                                        <span class="text-sm text-faint">(×{{ s }})</span>
                                    {% when None %}
                                    {% endmatch %}
                                </span>

                            {% when crate::web::templates::MenuSectionItem::Ingredient with { name, quantity, unit } %}
                                <span class="inline-flex items-center gap-1">
                                    <span class="ingredient-badge">
                                        {{ name }}
                                        {% match quantity %}
                                        {% when Some with (q) %}
                                            <span>{{ q }}</span>
                                        {% when None %}
                                        {% endmatch %}
                                        {% match unit %}
                                        {% when Some with (u) %}
                                            <span class="font-normal text-muted">{{ u }}</span>
                                        {% when None %}
                                        {% endmatch %}
                                    </span>
                                </span>
                            {% endmatch %}
                        {% endfor %}
                    </div>
                    {% endif %}
                    {% endfor %}
                </div>
            </div>
            {% endfor %}
    </div>
</div>

{% if !static_mode %}
<script>
// Escape a string for safe interpolation into HTML text content. Even
// though translated strings are compile-time today, defensively escaping
// matches the pattern used in shopping_list.html and prevents any future
// translation source from injecting markup via innerHTML.
function escHtml(s) {
    return String(s == null ? '' : s)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
}

// |json puts the path in a JS string literal with <, >, & escaped as
// \uXXXX, so it survives the <script> context.
const RECIPE_URL = {{ prefix|json|safe }} + '/recipe/' + {{ recipe_path|json|safe }};
const DEFAULT_SCALE = {{ scale }};

function goToScale(value) {
    const input = document.getElementById('scale');
    const n = parseFloat(value);
    if (!Number.isFinite(n)) {
        // Cleared or non-numeric: put the current scale back, do not navigate.
        input.value = DEFAULT_SCALE;
        return;
    }
    const min = parseFloat(input.min) || 0.5;
    const max = parseFloat(input.max) || 200;
    const clamped = Math.min(max, Math.max(min, n));
    window.location.href = RECIPE_URL + '?scale=' + encodeURIComponent(clamped);
}

async function addToShoppingList(event, recipePath) {
    event.preventDefault();
    const button = event.currentTarget;
    const originalText = button.innerHTML;

    try {
        const scale = document.getElementById('scale')?.value || 1;
        const response = await fetch('{{ prefix }}/api/shopping_list/add_menu', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                path: recipePath,
                scale: parseFloat(scale)
            })
        });

        if (response.ok) {
            button.innerHTML = '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> ' + escHtml({{ tr.t("recipe-added")|json|safe }});
            button.classList.add('bg-ok-soft', 'text-ok', 'border-ok');

            setTimeout(() => {
                button.innerHTML = originalText;
                button.classList.remove('bg-ok-soft', 'text-ok', 'border-ok');
            }, 2000);
        } else {
            throw new Error({{ tr.t("shopping-failed-to-add")|json|safe }});
        }
    } catch (error) {
        console.error('Error:', error);
        button.innerHTML = '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg> ' + escHtml({{ tr.t("shopping-error")|json|safe }});
        button.classList.add('bg-danger-soft', 'text-danger', 'border-danger');

        setTimeout(() => {
            button.innerHTML = originalText;
            button.classList.remove('bg-danger-soft', 'text-danger', 'border-danger');
        }, 2000);
    }
}
</script>
{% endif %}
{% endblock %}