feat: support external and step images with structured cooking instructions

This commit is contained in:
Sascha Nesterovic committed 2026-10-24 17:31:47 +02:00
1 parent 361862ad0b
commit 0ec1d45bdb
34 files changed
+898 -92

No files matched your search

+47
View File
@@ -17,6 +17,53 @@ describe('Versioned, safe, lossless recipe interchange', () => {
};
expect(parseImport(exportRecipes([r], 'xml'), 'xml').recipes[0]).toEqual(r);
});
it.each(['json', 'xml'] as const)(
'roundtrips external cover, gallery and step images in %s',
(format) => {
const r = recipeSchema.parse({
...pasta,
image: 'https://images.example.org/cover?width=1200&format=webp',
images: ['https://images.example.org/gallery.jpg'],
steps: pasta.steps.map((s, n) => ({
...s,
images:
n === 0 ? ['/images/kitchen.jpg', 'https://images.example.org/step.png?x=1&y=2'] : [],
})),
});
expect(parseImport(exportRecipes([r], format), format).recipes[0]).toEqual(r);
},
);
it('keeps older recipes without step images compatible', () => {
const r = { ...pasta, steps: pasta.steps.map(({ images, ...step }) => step) };
expect(
parseImport(JSON.stringify({ version: 1, recipes: [r] }), 'json').recipes[0].steps.every(
(s) => s.images.length === 0,
),
).toBe(true);
});
it.each([
'http://example.org/a.jpg',
'//example.org/a.jpg',
'javascript:alert(1)',
'data:image/png;base64,AAAA',
'https://user:password@example.org/a.jpg',
'https://',
'https://example.org/\\evil',
'https://example.org/' + 'a'.repeat(2048),
])('rejects unsupported image URL %s', (image) => {
expect(recipeSchema.safeParse({ ...pasta, image }).success).toBe(false);
expect(
recipeSchema.safeParse({ ...pasta, steps: [{ ...pasta.steps[0], images: [image] }] }).success,
).toBe(false);
});
it('limits step images', () => {
expect(
recipeSchema.safeParse({
...pasta,
steps: [{ ...pasta.steps[0], images: Array(11).fill('/images/kitchen.jpg') }],
}).success,
).toBe(false);
});
it('rejects unknown versions and missing mandatory fields', () => {
expect(() => parseImport('{"version":2,"recipes":[]}', 'json')).toThrow();
expect(() => parseImport('{"version":1,"recipes":[{"title":"Test"}]}', 'json')).toThrow();
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { instructionBlocks } from '../../src/lib/instructions';
describe('Readable plain-text instructions', () => {
it('preserves ingredient references, paragraphs and both list forms', () => {
expect(
instructionBlocks(
'Vorbereiten.\r\n\r\n- {{fleisch}} schneiden.\r\n- Salzen.\n\nAnschließend:\n1. Anbraten.\n2) Servieren.',
),
).toEqual([
{ type: 'paragraph', lines: ['Vorbereiten.'] },
{ type: 'unordered', lines: ['{{fleisch}} schneiden.', 'Salzen.'] },
{ type: 'paragraph', lines: ['Anschließend:'] },
{ type: 'ordered', lines: ['Anbraten.', 'Servieren.'] },
]);
});
it('keeps decimal quantities and temperatures intact when splitting legacy text', () => {
const text =
'Das Fleisch in 1.5 cm große Würfel schneiden und anschließend gründlich trocken tupfen. Die Pfanne auf mittlerer Hitze vorheizen und das Öl darin gleichmäßig verteilen. Das Fleisch vorsichtig hineinlegen und unter gelegentlichem Wenden goldbraun braten. Bei 180 °C im vorgeheizten Ofen fertig garen und anschließend servieren.';
const blocks = instructionBlocks(text);
expect(blocks.length).toBeGreaterThan(1);
expect(blocks.flatMap((b) => b.lines).join(' ')).toBe(text);
});
it('leaves short prose, explicit line breaks and literal HTML as text', () => {
expect(instructionBlocks('Bei 180 °C garen.')).toEqual([
{ type: 'paragraph', lines: ['Bei 180 °C garen.'] },
]);
expect(instructionBlocks('<script>test</script>\nFertig.')).toEqual([
{ type: 'paragraph', lines: ['<script>test</script>'] },
{ type: 'paragraph', lines: ['Fertig.'] },
]);
});
});