cookcli 0.37.0

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

{% block title %}{{ tr.t("nav-recipes") }} - Cook{% endblock %}

{% block content %}
<div>
    {% if !breadcrumbs.is_empty() %}
    <nav class="mb-4">
        <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>
                <a href="{{ prefix }}/directory/{{ crumb.path }}{% if static_mode %}.html{% endif %}" class="hover:text-text">{{ crumb.name }}</a>
            </li>
            {% endfor %}
        </ol>
    </nav>
    {% endif %}

    <div class="flex items-center justify-between mb-8">
        <h1 class="text-display font-bold text-text">
            {{ current_name }}
        </h1>
        {% if !static_mode %}
        <a href="{{ new_recipe_url }}" class="btn btn-primary">
            <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
            </svg>
            {{ tr.t("new-recipe") }}
        </a>
        {% endif %}
    </div>

    <div id="sort-controls" class="hidden items-center gap-2 mb-6">
        <label for="sort-field" class="text-sm text-muted">{{ tr.t("sort-by") }}</label>
        <select id="sort-field" class="select">
            <option value="name">{{ tr.t("sort-name") }}</option>
            <option value="modified">{{ tr.t("sort-modified") }}</option>
            <option value="created" id="sort-created-option">{{ tr.t("sort-created") }}</option>
        </select>
        <button id="sort-dir" type="button" class="btn" aria-label="{{ tr.t("sort-direction-toggle") }}" title="{{ tr.t("sort-direction-toggle") }}">↑</button>
    </div>

    {% match todays_menu %}
    {% when Some with (menu) %}
    <div class="mb-8 card p-6 border-l-[3px] border-l-accent">
        <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
            <div class="flex-1">
                <div class="flex items-center gap-3 mb-2">
                    <span class="text-2xl">📋</span>
                    <h2 class="text-title font-bold text-text">{{ tr.t("todays-menu-title") }}</h2>
                    <span class="text-sm text-faint">{{ menu.date_display }}</span>
                </div>
                <p class="text-sm text-muted">{{ tr.t("todays-menu-from") }}: {{ menu.menu_name }}</p>
            </div>
            <a href="{{ prefix }}/{% if static_mode %}menu{% else %}recipe{% endif %}/{{ menu.menu_path }}{% if static_mode %}.html{% endif %}" class="btn self-start sm:self-center">
                {{ tr.t("todays-menu-view") }}
                <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
                </svg>
            </a>
        </div>
    </div>
    {% when None %}
    {% endmatch %}

    <div id="recipes-grid" class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
        {% for item in items %}
        {% if item.is_directory %}
        <a href="{{ prefix }}/directory/{{ item.path }}{% if static_mode %}.html{% endif %}" data-type="directory" data-name="{{ item.name }}" class="recipe-card">
            <div class="p-6 flex-1">
                <div class="recipe-card-icon">
                    <span>📁</span>
                </div>
                <h2 class="recipe-card-title">{{ item.name }}</h2>
                {% match item.count %}
                {% when Some with (count) %}
                <span class="text-sm text-accent-text font-medium">{{ tr.tn("recipes-count", count) }}</span>
                {% when None %}
                {% endmatch %}
            </div>
        </a>
        {% else %}
        <a href="{{ prefix }}/{% if static_mode %}{% if item.is_menu %}menu{% else %}recipe{% endif %}{% else %}recipe{% endif %}/{{ item.path }}{% if static_mode %}.html{% endif %}"
           data-type="recipe"
           data-name="{{ item.name }}"
           {% if let Some(ts) = item.modified_at %}data-modified="{{ ts }}"{% endif %}
           {% if let Some(ts) = item.created_at %}data-created="{{ ts }}"{% endif %}
           class="recipe-card">
            {% match item.image_path %}
            {% when Some with (img) %}
            <div class="h-48 bg-sunk overflow-hidden">
                <img src="{{ img }}" alt="{{ item.name }}" class="w-full h-full object-cover">
            </div>
            <div class="p-6">
            {% when None %}
            <div class="p-6">
                <div class="recipe-card-icon">
                    <span>{% if item.is_menu %}📋{% else %}🍽️{% endif %}</span>
                </div>
            {% endmatch %}
                <div class="flex flex-wrap items-center gap-2">
                    <h2 class="recipe-card-title">{{ item.name }}</h2>
                    {% if item.is_menu %}
                    <span class="tag mb-2">{{ tr.t("recipe-type-menu") }}</span>
                    {% endif %}
                </div>
                {% match item.description %}
                {% when Some with (description) %}
                <p class="text-muted text-sm">{{ description }}</p>
                {% when None %}
                {% endmatch %}
                {% if !item.tags.is_empty() %}
                <div class="mt-3 flex flex-wrap gap-2">
                    {% for tag in item.tags.iter().take(3) %}
                    <span class="tag">{{ tag }}</span>
                    {% endfor %}
                    {% if item.tags.len() > 3 %}
                    <span class="text-xs text-faint">+{{ item.tags.len() - 3 }}</span>
                    {% endif %}
                </div>
                {% endif %}
            </div>
        </a>
        {% endif %}
        {% endfor %}

        {% if items.is_empty() %}
        <div class="text-faint text-center py-8">
            {{ tr.t("recipes-empty") }}
        </div>
        {% endif %}
    </div>
</div>
{% endblock %}

{% block scripts %}
<script>
(function () {
    const grid = document.getElementById('recipes-grid');
    const controls = document.getElementById('sort-controls');
    const fieldSelect = document.getElementById('sort-field');
    const dirBtn = document.getElementById('sort-dir');
    const createdOption = document.getElementById('sort-created-option');

    if (!grid || !controls || !fieldSelect || !dirBtn) return;

    // Sorting is client-side because this template is also rendered by the
    // static site builder, where there is no server to answer a ?sort= query.
    const STORE_KEY = 'recipes-sort';

    let sortField = 'name';
    let sortDir = 'asc';

    const recipeCards = Array.from(grid.querySelectorAll('[data-type="recipe"]'));

    // "Created" is only offered when every recipe has a creation date;
    // otherwise the column is partly empty and the ordering is arbitrary.
    const allHaveCreated = recipeCards.length > 0 &&
        recipeCards.every(el => el.hasAttribute('data-created'));
    if (!allHaveCreated && createdOption) {
        createdOption.remove();
    }

    // Nothing to sort with fewer than two recipes.
    if (recipeCards.length < 2) return;
    controls.classList.remove('hidden');
    controls.classList.add('flex');

    function restore() {
        let saved = null;
        try {
            saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null');
        } catch (e) {
            // Corrupt value: fall through to the defaults.
        }
        if (!saved) return;
        // The saved field may not exist here — "created" is removed on pages
        // where not every recipe has a date. Treat an invalid field as fully
        // corrupt so a stale direction cannot pair with the wrong field.
        const valid = Array.from(fieldSelect.options).some(o => o.value === saved.field);
        if (!valid) return;
        sortField = saved.field;
        fieldSelect.value = saved.field;
        if (saved.dir === 'asc' || saved.dir === 'desc') {
            sortDir = saved.dir;
        }
    }

    function persist() {
        try {
            sessionStorage.setItem(STORE_KEY, JSON.stringify({ field: sortField, dir: sortDir }));
        } catch (e) {
            // Private browsing or a full quota: sorting still works, it just
            // does not survive navigation.
        }
    }

    function getValue(el) {
        if (sortField === 'name') {
            // data-name rather than the heading text, so a markup change
            // cannot silently break sorting.
            return (el.getAttribute('data-name') || '').trim();
        }
        const raw = el.getAttribute(sortField === 'modified' ? 'data-modified' : 'data-created');
        if (raw === null) return null;
        const n = parseInt(raw, 10);
        return Number.isNaN(n) ? null : n;
    }

    // numeric so "Recipe 10" sorts after "Recipe 9"; sensitivity 'base' so
    // case and accents do not split otherwise-equal names.
    const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });

    function compare(a, b) {
        const av = getValue(a);
        const bv = getValue(b);
        // Missing values sort last in both directions.
        if (av === null && bv === null) return 0;
        if (av === null) return 1;
        if (bv === null) return -1;
        const cmp = typeof av === 'string' ? collator.compare(av, bv) : av - bv;
        return sortDir === 'asc' ? cmp : -cmp;
    }

    function applySort() {
        const all = Array.from(grid.children);
        const dirs = all.filter(el => el.dataset.type === 'directory');
        const recipes = all.filter(el => el.dataset.type === 'recipe');
        const others = all.filter(el => !el.dataset.type);

        recipes.sort(compare);
        // Directories stay grouped above recipes, sorted by name among
        // themselves so their order is deterministic. They only follow
        // sortDir when sorting by name; for date fields they stay A→Z since
        // directories have no modified/created date of their own.
        dirs.sort((a, b) => {
            const cmp = collator.compare(a.getAttribute('data-name') || '', b.getAttribute('data-name') || '');
            return sortField === 'name' && sortDir === 'desc' ? -cmp : cmp;
        });

        const frag = document.createDocumentFragment();
        dirs.forEach(el => frag.appendChild(el));
        recipes.forEach(el => frag.appendChild(el));
        others.forEach(el => frag.appendChild(el));
        grid.appendChild(frag);

        dirBtn.textContent = sortDir === 'asc' ? '↑' : '↓';
    }

    fieldSelect.addEventListener('change', function () {
        sortField = this.value;
        // Dates default to newest first; names to A–Z.
        sortDir = sortField === 'name' ? 'asc' : 'desc';
        applySort();
        persist();
    });

    dirBtn.addEventListener('click', function () {
        sortDir = sortDir === 'asc' ? 'desc' : 'asc';
        applySort();
        persist();
    });

    restore();
    // The server already renders name/asc (natural, case-folded), so on the
    // default sort the DOM is left as served: no reorder on load, and names
    // with accents keep the server's order instead of the collator's.
    if (sortField !== 'name' || sortDir !== 'asc') {
        applySort();
    }
})();
</script>
{% endblock %}