cooking/lib/recipe-content.test.ts
Olomana (multica agent) b7c526a784 PWS-13: Make recipe markdown fully control the tabs
Every top-level :::name block is now a tab, shown in file order, so
authors can add custom tabs and reorder them in the markdown. Titles
come from the name or a :::name[Title] label.

The recipe card is an ordinary :::card tab. Its body lists what it
prints (::ingredients, or a block written just for the card), replacing
::card, the fallback to full sections, and its fixed tab position.

All recipes are migrated, with pages and card images unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-11-07 00:34:10 -08:00

94 lines
4.6 KiB
TypeScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import matter from 'gray-matter';
import { parseRecipeContent, type CardTab } from './recipe-content';
const parse = (source: string) => parseRecipeContent(source, 'test.md');
const tabNames = (source: string) => parse(source).tabs.map((tab) => tab.name);
const card = (source: string) => parse(source).tabs.find((tab) => tab.type === 'card') as CardTab;
test('tabs follow file order, with no name or position treated specially', () => {
const source = ':::references\n- a\n:::\n\n:::wine\n- b\n:::\n\n:::ingredients\n- c\n:::\n';
assert.deepEqual(tabNames(source), ['references', 'wine', 'ingredients']);
});
test('titles come from the label, otherwise from the name', () => {
const { tabs } = parse(':::wine-pairing\nx\n:::\n\n:::notes[Chef Tips]\ny\n:::\n');
assert.deepEqual(tabs.map((tab) => tab.title), ['Wine Pairing', 'Chef Tips']);
});
test('a label is not rendered as tab content', () => {
const [tab] = parse(':::notes[Chef Tips]\nbody\n:::\n').tabs;
assert.equal(tab.type === 'markdown' && tab.content.children.length, 1);
});
test('a tab with no content is still a tab', () => {
assert.deepEqual(tabNames(':::notes\n:::\n'), ['notes']);
});
test('prose before and after the tabs is intro and outro; prose between them is an error', () => {
const { intro, outro } = parse('before\n\n:::notes\nx\n:::\n\nafter\n');
assert.equal(intro.children.length, 1);
assert.equal(outro.children.length, 1);
assert.throws(() => parse(':::a\nx\n:::\n\nbetween\n\n:::b\ny\n:::\n'), /test\.md:5: Content between tabs/);
});
test('recipes without a card have no card tab', () => {
assert.equal(card(':::ingredients\n- a\n:::\n'), undefined);
});
test('the card sits where it is written and has its own title', () => {
const source = ':::notes\nx\n:::\n\n:::card[Print Me]\n::notes\n:::\n\n:::references\ny\n:::\n';
assert.deepEqual(tabNames(source), ['notes', 'card', 'references']);
assert.equal(card(source).title, 'Print Me');
});
test('the card shows only what it lists, in the order listed', () => {
const source = ':::a\nx\n:::\n\n:::b\ny\n:::\n\n:::c\nz\n:::\n\n:::card\n::c\n::a\n:::\n';
assert.deepEqual(card(source).sections.map((s) => s.name), ['c', 'a']);
});
test('the card can list a tab written after it', () => {
assert.deepEqual(card(':::card\n::notes\n:::\n\n:::notes\nx\n:::\n').sections.map((s) => s.name), ['notes']);
});
test('the card mixes references with blocks written just for it', () => {
const source = ':::ingredients\n- a\n- b\n:::\n\n::::card\n::ingredients\n:::instructions[Short Steps]\n1. x\n:::\n::::\n';
assert.deepEqual(card(source).sections.map((s) => s.title), ['Ingredients', 'Short Steps']);
});
test('invalid cards fail with the file and line', () => {
assert.throws(() => parse(':::card\n::missing\n:::\n'), /test\.md:2: .*no :::missing tab/);
assert.throws(() => parse(':::card\n:::\n'), /test\.md:1: .*one or two sections/);
assert.throws(() => parse(':::a\nx\n:::\n\n:::card\n::a\n::a\n:::\n'), /test\.md:7: .*more than once/);
assert.throws(() => parse(':::a\nx\n:::\n:::b\ny\n:::\n:::c\nz\n:::\n:::card\n::a\n::b\n::c\n:::\n'), /one or two sections/);
assert.throws(() => parse(':::card\nsome prose\n:::\n'), /test\.md:2: .*may only contain/);
});
test('duplicate tabs and top-level leaf directives are errors', () => {
assert.throws(() => parse(':::notes\nx\n:::\n\n:::notes\ny\n:::\n'), /test\.md:5: Duplicate :::notes/);
assert.throws(() => parse('::card\n'), /test\.md:1: Tabs need the container form/);
});
test('`:word` in prose stays literal, and image paragraphs become figures', () => {
const [tab] = parse(':::notes\nTip:Use salt\n\n![Alt](./a.jpg "Cap")\n:::\n').tabs;
assert(tab.type === 'markdown');
const [prose, figure] = tab.content.children;
assert.equal(prose.type === 'paragraph' && prose.children.map((c) => ('value' in c ? c.value : '')).join(''), 'Tip:Use salt');
assert.equal(figure.data?.hName, 'figure');
});
test('every recipe in the repo parses and declares its card', () => {
const recipesDir = path.join(process.cwd(), 'recipes');
const files = fs.readdirSync(recipesDir, { recursive: true, encoding: 'utf8' })
.filter((file) => file.endsWith('.md') && path.basename(file).toLowerCase() !== 'readme.md');
assert(files.length > 0);
for (const file of files) {
const { content } = matter(fs.readFileSync(path.join(recipesDir, file), 'utf8'));
const parsed = parseRecipeContent(content, file);
assert(parsed.tabs.some((tab) => tab.type === 'card'), `${file} has no :::card`);
}
});