├── .gitignore ├── .github └── FUNDING.yml ├── .npmignore ├── jest.config.js ├── tsconfig.json ├── LICENSE ├── README.md ├── package.json ├── dist ├── cooklang.d.ts └── cooklang.js ├── src └── cooklang.ts └── __tests__ ├── recipe.test.ts └── canonical.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: deathau 2 | custom: ["https://www.paypal.me/deathau"] 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | src/ 4 | __tests__/ 5 | jest.config.js 6 | tsconfig.json -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "types": ["jest", "node"], 6 | "strict": true, 7 | "noImplicitAny": true, 8 | "declaration": true, 9 | "outDir": "dist" 10 | }, 11 | "include": [ 12 | "src/**/*.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright 2021 Gordon Pedersen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CookLang-JS 2 | CookLang-JS is a JavaScript library for parsing CookLang. 3 | 4 | ## Installation 5 | `npm install cooklang` 6 | 7 | ## Usage 8 | ```javascript 9 | import { Recipe } from 'cooklang' 10 | 11 | const recipeString = `` // <- Your CookLang recipe 12 | const recipe = new Recipe(recipeString); 13 | console.log(recipe); 14 | ``` 15 | 16 | ## Documentation 17 | For the recipe structure and other documentation, check out cooklang.d.ts 18 | 19 | ## Contributing 20 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 21 | 22 | Please make sure to update tests as appropriate. 23 | 24 | ## Tests 25 | Canonical tests as at https://github.com/cooklang/spec/blob/f67e56c69564369c785a93a28eeda2ed5b51c5ff/tests/canonical.yaml 26 | (in the `__tests__` folder) 27 | TODO: I want to keep the tests up-to-date with the meain branch instead of copying from the repo 28 | 29 | ## License 30 | [MIT No Attribution](./LICENCE) 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cooklang", 3 | "version": "0.3.1", 4 | "description": "A library for parsing CookLang in JavaScript", 5 | "keywords": [ 6 | "cooklang" 7 | ], 8 | "homepage": "https://github.com/deathau/cooklang-js#readme", 9 | "bugs": { 10 | "url": "https://github.com/deathau/cooklang-js/issues" 11 | }, 12 | "license": "MIT-0", 13 | "author": "death_au", 14 | "funding": [ 15 | { 16 | "type": "github", 17 | "url": "https://github.com/sponsors/deathau" 18 | }, 19 | { 20 | "type": "paypal", 21 | "url": "https://www.paypal.me/deathau" 22 | } 23 | ], 24 | "main": "dist/cooklang.js", 25 | "types": "dist/cooklang.d.ts", 26 | "repository": "github:deathau/cooklang-js", 27 | "scripts": { 28 | "build": "tsc", 29 | "test": "npx jest --verbose --", 30 | "test:watch": "npx jest --watch --", 31 | "coverage": "npx jest --collectCoverage --" 32 | }, 33 | "devDependencies": { 34 | "@types/jest": "^27.0.3", 35 | "ts-jest": "^27.0.7", 36 | "typescript": "^4.5.2", 37 | "yaml": "^1.10.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /dist/cooklang.d.ts: -------------------------------------------------------------------------------- 1 | declare class base { 2 | raw?: string; 3 | constructor(s: string | string[] | any); 4 | } 5 | export declare class Recipe extends base { 6 | metadata: Metadata[]; 7 | ingredients: Ingredient[]; 8 | cookware: Cookware[]; 9 | timers: Timer[]; 10 | steps: Step[]; 11 | image?: any; 12 | constructor(s?: string); 13 | calculateTotalTime(): number; 14 | } 15 | export declare class Step extends base { 16 | line: (string | base)[]; 17 | image?: any; 18 | constructor(s?: string | any); 19 | parseLine(s: string): (string | base)[]; 20 | } 21 | export declare class Ingredient extends base { 22 | name?: string; 23 | amount?: string; 24 | quantity?: number; 25 | units?: string; 26 | constructor(s: string | string[] | any); 27 | } 28 | export declare class Cookware extends base { 29 | name?: string; 30 | amount?: string; 31 | quantity?: number; 32 | constructor(s?: string | string[] | any); 33 | } 34 | export declare class Timer extends base { 35 | name?: string; 36 | amount?: string; 37 | quantity?: number; 38 | units?: string; 39 | seconds?: number; 40 | constructor(s?: string | string[] | any); 41 | static getSeconds(amount: number, unit?: string): number; 42 | } 43 | export declare class Metadata extends base { 44 | key?: string; 45 | value?: string; 46 | constructor(s: string | string[] | any); 47 | } 48 | export {}; 49 | -------------------------------------------------------------------------------- /src/cooklang.ts: -------------------------------------------------------------------------------- 1 | const COMMENT_REGEX = /(--.*)|(\[-(.|\n)+?-\])/g 2 | const INGREDIENT_REGEX = /@(?:([^@#~]+?)(?:{(.*?)}|{\s*}))|@((?:[^@#~\s])+)/ 3 | const COOKWARE_REGEX = /#(?:([^@#~]+?)(?:{(.*?)}|{\s*}))|#((?:[^@#~\s])+)/ 4 | const TIMER_REGEX = /~([^@#~]*){([0-9]+(?:[\/|\.][0-9]+)?)%(.+?)}/ 5 | const METADATA_REGEX = /^>>\s*(.*?):\s*(.*)$/ 6 | 7 | // a base class containing the raw string 8 | class base { 9 | raw?: string 10 | 11 | constructor(s: string | string[] | any) { 12 | if (s instanceof Array) this.raw = s[0] 13 | else if (typeof s === 'string') this.raw = s 14 | else if ('raw' in s) this.raw = s.raw 15 | } 16 | } 17 | 18 | export class Recipe extends base { 19 | metadata: Metadata[] = [] 20 | ingredients: Ingredient[] = [] 21 | cookware: Cookware[] = [] 22 | timers: Timer[] = [] 23 | steps: Step[] = [] 24 | image?: any 25 | 26 | constructor(s?: string) { 27 | super(s) 28 | s?.replace(COMMENT_REGEX, '')?.split('\n')?.forEach(line => { 29 | if (line.trim()) { 30 | let l = new Step(line) 31 | if (l.line.length != 0) { 32 | if (l.line.length == 1 && l.line[0] instanceof Metadata) { 33 | this.metadata.push(l.line[0]) 34 | } 35 | else { 36 | l.line.forEach(b => { 37 | if (b instanceof Ingredient) this.ingredients.push(b) 38 | else if (b instanceof Cookware) this.cookware.push(b) 39 | else if (b instanceof Timer) this.timers.push(b) 40 | }) 41 | this.steps.push(l) 42 | } 43 | } 44 | } 45 | }) 46 | } 47 | 48 | calculateTotalTime() { 49 | return this.timers.reduce((a, b) => a + (b.seconds || 0), 0) 50 | } 51 | } 52 | 53 | 54 | // a single recipe step 55 | export class Step extends base { 56 | line: (string | base)[] = [] 57 | image?: any 58 | 59 | constructor(s?: string | any) { 60 | super(s) 61 | if (s && typeof s === 'string') this.line = this.parseLine(s) 62 | else if(s) { 63 | if ('line' in s) this.line = s.line 64 | if ('image' in s) this.image = s.image 65 | } 66 | } 67 | 68 | // parse a single line 69 | parseLine(s: string): (string | base)[] { 70 | let match: RegExpExecArray | null 71 | let b: base | undefined 72 | let line: (string | base)[] = [] 73 | // if it's a metadata line, return that 74 | if (match = METADATA_REGEX.exec(s)) { 75 | return [new Metadata(match)] 76 | } 77 | // if it has an ingredient, pull that out 78 | else if (match = INGREDIENT_REGEX.exec(s)) { 79 | b = new Ingredient(match) 80 | } 81 | // if it has an item of cookware, pull that out 82 | else if (match = COOKWARE_REGEX.exec(s)) { 83 | b = new Cookware(match) 84 | } 85 | // if it has a timer, pull that out 86 | else if (match = TIMER_REGEX.exec(s)) { 87 | b = new Timer(match) 88 | } 89 | 90 | // if we found something (ingredient, cookware, timer) 91 | if (b && b.raw) { 92 | // split the string up to get the string left and right of what we found 93 | const split = s.split(b.raw) 94 | // if the line doesn't start with what we matched, we need to parse the left side 95 | if (!s.startsWith(b.raw)) line.unshift(...this.parseLine(split[0])) 96 | // add what we matched in the middle 97 | line.push(b) 98 | // if the line doesn't end with what we matched, we need to parse the right side 99 | if (!s.endsWith(b.raw)) line.push(...this.parseLine(split[1])) 100 | 101 | return line 102 | } 103 | // if it doesn't match any regular expressions, just return the whole string 104 | return [s] 105 | } 106 | } 107 | 108 | // ingredients 109 | export class Ingredient extends base { 110 | name?: string 111 | amount?: string 112 | quantity?: number 113 | units?: string 114 | 115 | constructor(s: string | string[] | any) { 116 | super(s) 117 | if (s instanceof Array || typeof s === 'string') { 118 | const match = s instanceof Array ? s : INGREDIENT_REGEX.exec(s) 119 | if (!match || match.length != 4) throw `error parsing ingredient: '${s}'` 120 | this.name = (match[1] || match[3]).trim() 121 | const attrs = match[2]?.split('%') 122 | this.amount = attrs && attrs.length > 0 ? attrs[0].trim() : "1" 123 | if(!this.amount) this.amount = "1" 124 | this.quantity = this.amount ? stringToNumber(this.amount) : 1 125 | this.units = attrs && attrs.length > 1 ? attrs[1].trim() : "" 126 | } 127 | else { 128 | if ('name' in s) this.name = s.name 129 | if ('amount' in s) this.amount = s.amount 130 | if ('quantity' in s) this.quantity = s.quantity 131 | if ('units' in s) this.units = s.units 132 | } 133 | } 134 | } 135 | 136 | // cookware 137 | export class Cookware extends base { 138 | name?: string 139 | amount?: string 140 | quantity?: number 141 | 142 | constructor(s?: string | string[] | any) { 143 | super(s) 144 | if (s instanceof Array || typeof s === 'string') { 145 | const match = s instanceof Array ? s : COOKWARE_REGEX.exec(s) 146 | if (!match || match.length != 4) throw `error parsing ingredient: '${s}'` 147 | this.name = (match[1] || match[3]).trim() 148 | const attrs = match[2] 149 | this.amount = attrs && attrs.length > 0 ? attrs[0].trim() : "1" 150 | if(!this.amount) this.amount = "1" 151 | this.quantity = this.amount ? stringToNumber(this.amount) : 1 152 | } 153 | else { 154 | if ('name' in s) this.name = s.name 155 | if ('amount' in s) this.amount = s.amount 156 | if ('quantity' in s) this.quantity = s.quantity 157 | } 158 | } 159 | } 160 | 161 | // timer 162 | export class Timer extends base { 163 | name?: string 164 | amount?: string 165 | quantity?: number 166 | units?: string 167 | seconds?: number 168 | 169 | constructor(s?: string | string[] | any) { 170 | super(s) 171 | 172 | if (s instanceof Array || typeof s === 'string') { 173 | const match = s instanceof Array ? s : TIMER_REGEX.exec(s) 174 | if (!match || match.length != 4) throw `error parsing timer: '${s}'` 175 | this.name = match[1] ? match[1].trim() : "" 176 | this.amount = match[2] ? match[2].trim() : 0 177 | this.units = match[3] ? match[3].trim() : "" 178 | this.quantity = this.amount ? stringToNumber(this.amount) : 0 179 | this.seconds = Timer.getSeconds(this.quantity, this.units) 180 | } 181 | else { 182 | if ('name' in s) this.name = s.name 183 | if ('amount' in s) this.amount = s.amount 184 | if ('quantity' in s) this.quantity = s.quantity 185 | if ('units' in s) this.units = s.units 186 | if ('seconds' in s) this.seconds = s.seconds 187 | } 188 | } 189 | 190 | static getSeconds(amount: number, unit: string = 'm') { 191 | let time = 0 192 | 193 | if (amount > 0) { 194 | if (unit.toLowerCase().startsWith('s')) { 195 | time = amount 196 | } 197 | else if (unit.toLowerCase().startsWith('m')) { 198 | time = amount * 60 199 | } 200 | else if (unit.toLowerCase().startsWith('h')) { 201 | time = amount * 60 * 60 202 | } 203 | } 204 | 205 | return time 206 | } 207 | } 208 | 209 | function stringToNumber(s: string){ 210 | let amount: number = 0 211 | if (parseFloat(s) + '' == s) amount = parseFloat(s) 212 | else if (s.includes('/')) { 213 | const split = s.split('/') 214 | if (split.length == 2) { 215 | const num = parseFloat(split[0].trim()) 216 | const den = parseFloat(split[1].trim()) 217 | if (num + '' == split[0].trim() && den + '' == split[1].trim()) { 218 | amount = num / den 219 | } 220 | else amount = NaN 221 | } 222 | } 223 | else amount = NaN 224 | return amount; 225 | } 226 | 227 | // metadata 228 | export class Metadata extends base { 229 | key?: string 230 | value?: string 231 | 232 | constructor(s: string | string[] | any) { 233 | super(s) 234 | 235 | if (s instanceof Array || typeof s === 'string') { 236 | const match = s instanceof Array ? s : METADATA_REGEX.exec(s) 237 | if (!match || match.length != 3) throw `error parsing metadata: '${s}'` 238 | this.key = match[1].trim() 239 | this.value = match[2].trim() 240 | } 241 | else { 242 | if ('key' in s) this.key = s.key 243 | if ('value' in s) this.value = s.value 244 | } 245 | } 246 | } -------------------------------------------------------------------------------- /dist/cooklang.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.Metadata = exports.Timer = exports.Cookware = exports.Ingredient = exports.Step = exports.Recipe = void 0; 4 | const COMMENT_REGEX = /(--.*)|(\[-(.|\n)+?-\])/g; 5 | const INGREDIENT_REGEX = /@(?:([^@#~]+?)(?:{(.*?)}|{\s*}))|@((?:[^@#~\s])+)/; 6 | const COOKWARE_REGEX = /#(?:([^@#~]+?)(?:{(.*?)}|{\s*}))|#((?:[^@#~\s])+)/; 7 | const TIMER_REGEX = /~([^@#~]*){([0-9]+(?:[\/|\.][0-9]+)?)%(.+?)}/; 8 | const METADATA_REGEX = /^>>\s*(.*?):\s*(.*)$/; 9 | // a base class containing the raw string 10 | class base { 11 | constructor(s) { 12 | if (s instanceof Array) 13 | this.raw = s[0]; 14 | else if (typeof s === 'string') 15 | this.raw = s; 16 | else if ('raw' in s) 17 | this.raw = s.raw; 18 | } 19 | } 20 | class Recipe extends base { 21 | constructor(s) { 22 | var _a, _b; 23 | super(s); 24 | this.metadata = []; 25 | this.ingredients = []; 26 | this.cookware = []; 27 | this.timers = []; 28 | this.steps = []; 29 | (_b = (_a = s === null || s === void 0 ? void 0 : s.replace(COMMENT_REGEX, '')) === null || _a === void 0 ? void 0 : _a.split('\n')) === null || _b === void 0 ? void 0 : _b.forEach(line => { 30 | if (line.trim()) { 31 | let l = new Step(line); 32 | if (l.line.length != 0) { 33 | if (l.line.length == 1 && l.line[0] instanceof Metadata) { 34 | this.metadata.push(l.line[0]); 35 | } 36 | else { 37 | l.line.forEach(b => { 38 | if (b instanceof Ingredient) 39 | this.ingredients.push(b); 40 | else if (b instanceof Cookware) 41 | this.cookware.push(b); 42 | else if (b instanceof Timer) 43 | this.timers.push(b); 44 | }); 45 | this.steps.push(l); 46 | } 47 | } 48 | } 49 | }); 50 | } 51 | calculateTotalTime() { 52 | return this.timers.reduce((a, b) => a + (b.seconds || 0), 0); 53 | } 54 | } 55 | exports.Recipe = Recipe; 56 | // a single recipe step 57 | class Step extends base { 58 | constructor(s) { 59 | super(s); 60 | this.line = []; 61 | if (s && typeof s === 'string') 62 | this.line = this.parseLine(s); 63 | else if (s) { 64 | if ('line' in s) 65 | this.line = s.line; 66 | if ('image' in s) 67 | this.image = s.image; 68 | } 69 | } 70 | // parse a single line 71 | parseLine(s) { 72 | let match; 73 | let b; 74 | let line = []; 75 | // if it's a metadata line, return that 76 | if (match = METADATA_REGEX.exec(s)) { 77 | return [new Metadata(match)]; 78 | } 79 | // if it has an ingredient, pull that out 80 | else if (match = INGREDIENT_REGEX.exec(s)) { 81 | b = new Ingredient(match); 82 | } 83 | // if it has an item of cookware, pull that out 84 | else if (match = COOKWARE_REGEX.exec(s)) { 85 | b = new Cookware(match); 86 | } 87 | // if it has a timer, pull that out 88 | else if (match = TIMER_REGEX.exec(s)) { 89 | b = new Timer(match); 90 | } 91 | // if we found something (ingredient, cookware, timer) 92 | if (b && b.raw) { 93 | // split the string up to get the string left and right of what we found 94 | const split = s.split(b.raw); 95 | // if the line doesn't start with what we matched, we need to parse the left side 96 | if (!s.startsWith(b.raw)) 97 | line.unshift(...this.parseLine(split[0])); 98 | // add what we matched in the middle 99 | line.push(b); 100 | // if the line doesn't end with what we matched, we need to parse the right side 101 | if (!s.endsWith(b.raw)) 102 | line.push(...this.parseLine(split[1])); 103 | return line; 104 | } 105 | // if it doesn't match any regular expressions, just return the whole string 106 | return [s]; 107 | } 108 | } 109 | exports.Step = Step; 110 | // ingredients 111 | class Ingredient extends base { 112 | constructor(s) { 113 | var _a; 114 | super(s); 115 | if (s instanceof Array || typeof s === 'string') { 116 | const match = s instanceof Array ? s : INGREDIENT_REGEX.exec(s); 117 | if (!match || match.length != 4) 118 | throw `error parsing ingredient: '${s}'`; 119 | this.name = (match[1] || match[3]).trim(); 120 | const attrs = (_a = match[2]) === null || _a === void 0 ? void 0 : _a.split('%'); 121 | this.amount = attrs && attrs.length > 0 ? attrs[0].trim() : "1"; 122 | if (!this.amount) 123 | this.amount = "1"; 124 | this.quantity = this.amount ? stringToNumber(this.amount) : 1; 125 | this.units = attrs && attrs.length > 1 ? attrs[1].trim() : ""; 126 | } 127 | else { 128 | if ('name' in s) 129 | this.name = s.name; 130 | if ('amount' in s) 131 | this.amount = s.amount; 132 | if ('quantity' in s) 133 | this.quantity = s.quantity; 134 | if ('units' in s) 135 | this.units = s.units; 136 | } 137 | } 138 | } 139 | exports.Ingredient = Ingredient; 140 | // cookware 141 | class Cookware extends base { 142 | constructor(s) { 143 | super(s); 144 | if (s instanceof Array || typeof s === 'string') { 145 | const match = s instanceof Array ? s : COOKWARE_REGEX.exec(s); 146 | if (!match || match.length != 4) 147 | throw `error parsing ingredient: '${s}'`; 148 | this.name = (match[1] || match[3]).trim(); 149 | const attrs = match[2]; 150 | this.amount = attrs && attrs.length > 0 ? attrs[0].trim() : "1"; 151 | if (!this.amount) 152 | this.amount = "1"; 153 | this.quantity = this.amount ? stringToNumber(this.amount) : 1; 154 | } 155 | else { 156 | if ('name' in s) 157 | this.name = s.name; 158 | if ('amount' in s) 159 | this.amount = s.amount; 160 | if ('quantity' in s) 161 | this.quantity = s.quantity; 162 | } 163 | } 164 | } 165 | exports.Cookware = Cookware; 166 | // timer 167 | class Timer extends base { 168 | constructor(s) { 169 | super(s); 170 | if (s instanceof Array || typeof s === 'string') { 171 | const match = s instanceof Array ? s : TIMER_REGEX.exec(s); 172 | if (!match || match.length != 4) 173 | throw `error parsing timer: '${s}'`; 174 | this.name = match[1] ? match[1].trim() : ""; 175 | this.amount = match[2] ? match[2].trim() : 0; 176 | this.units = match[3] ? match[3].trim() : ""; 177 | this.quantity = this.amount ? stringToNumber(this.amount) : 0; 178 | this.seconds = Timer.getSeconds(this.quantity, this.units); 179 | } 180 | else { 181 | if ('name' in s) 182 | this.name = s.name; 183 | if ('amount' in s) 184 | this.amount = s.amount; 185 | if ('quantity' in s) 186 | this.quantity = s.quantity; 187 | if ('units' in s) 188 | this.units = s.units; 189 | if ('seconds' in s) 190 | this.seconds = s.seconds; 191 | } 192 | } 193 | static getSeconds(amount, unit = 'm') { 194 | let time = 0; 195 | if (amount > 0) { 196 | if (unit.toLowerCase().startsWith('s')) { 197 | time = amount; 198 | } 199 | else if (unit.toLowerCase().startsWith('m')) { 200 | time = amount * 60; 201 | } 202 | else if (unit.toLowerCase().startsWith('h')) { 203 | time = amount * 60 * 60; 204 | } 205 | } 206 | return time; 207 | } 208 | } 209 | exports.Timer = Timer; 210 | function stringToNumber(s) { 211 | let amount = 0; 212 | if (parseFloat(s) + '' == s) 213 | amount = parseFloat(s); 214 | else if (s.includes('/')) { 215 | const split = s.split('/'); 216 | if (split.length == 2) { 217 | const num = parseFloat(split[0].trim()); 218 | const den = parseFloat(split[1].trim()); 219 | if (num + '' == split[0].trim() && den + '' == split[1].trim()) { 220 | amount = num / den; 221 | } 222 | else 223 | amount = NaN; 224 | } 225 | } 226 | else 227 | amount = NaN; 228 | return amount; 229 | } 230 | // metadata 231 | class Metadata extends base { 232 | constructor(s) { 233 | super(s); 234 | if (s instanceof Array || typeof s === 'string') { 235 | const match = s instanceof Array ? s : METADATA_REGEX.exec(s); 236 | if (!match || match.length != 3) 237 | throw `error parsing metadata: '${s}'`; 238 | this.key = match[1].trim(); 239 | this.value = match[2].trim(); 240 | } 241 | else { 242 | if ('key' in s) 243 | this.key = s.key; 244 | if ('value' in s) 245 | this.value = s.value; 246 | } 247 | } 248 | } 249 | exports.Metadata = Metadata; 250 | -------------------------------------------------------------------------------- /__tests__/recipe.test.ts: -------------------------------------------------------------------------------- 1 | import { Recipe, Step, Metadata, Ingredient, Cookware, Timer } from '../src/cooklang' 2 | 3 | let tests: any; 4 | 5 | // Include fs module 6 | const fs = require('fs'); 7 | 8 | const data = fs.readFileSync('./__tests__/canonical.yaml', 9 | { encoding: 'utf8', flag: 'r' }); 10 | 11 | const YAML = require('yaml') 12 | 13 | const parsed = YAML.parse(data) 14 | tests = parsed.tests 15 | 16 | describe.each(Object.keys(tests))("canonical tests", (testName: string) => { 17 | test(testName, () => { 18 | const source = tests[testName].source 19 | const result = tests[testName].result 20 | 21 | const recipe = new Recipe(source) 22 | 23 | // test the metadata 24 | const resultMetadataKeys = Object.keys(result.metadata) 25 | expect(recipe.metadata.length).toBe(resultMetadataKeys.length) 26 | for (let m = 0; m < resultMetadataKeys.length; m++) { 27 | const recipeMetadata: Metadata = recipe.metadata[m] 28 | 29 | const resultKey: string = resultMetadataKeys[m] 30 | const resultValue: string = result.metadata[resultKey] 31 | 32 | expect(recipeMetadata.key).toEqual(resultKey) 33 | expect(recipeMetadata.value).toEqual(resultValue) 34 | } 35 | 36 | // test the steps 37 | expect(recipe.steps.length).toBe(result.steps.length) 38 | for (let lineNo = 0; lineNo < result.steps.length; lineNo++) { 39 | const recipeStep:Step = recipe.steps[lineNo] 40 | const resultStep: any = result.steps[lineNo] 41 | 42 | expect(recipeStep.line.length).toBe(resultStep.length) 43 | for (let i = 0; i < recipeStep.line.length; i++){ 44 | const recipeComponent: any = recipeStep.line[i]; 45 | const resultComponent: any = resultStep[i]; 46 | switch (resultComponent.type) { 47 | case "text": 48 | expect(typeof recipeComponent).toBe("string") 49 | expect(recipeComponent).toEqual(resultComponent.value) 50 | break; 51 | case "ingredient": 52 | expect(recipeComponent).toBeInstanceOf(Ingredient) 53 | const ingredient = recipeComponent as Ingredient 54 | expect(ingredient.name).toBe(resultComponent.name) 55 | // split in logic here. For non-number quantities, the string is still in the "amount" field 56 | if(typeof ingredient.quantity === 'undefined' || isNaN(ingredient.quantity)) 57 | expect(ingredient.amount).toBe(resultComponent.quantity) 58 | else 59 | expect(ingredient.quantity).toBe(resultComponent.quantity) 60 | expect(ingredient.units).toBe(resultComponent.units) 61 | break; 62 | case "cookware": 63 | expect(recipeComponent).toBeInstanceOf(Cookware) 64 | const cookware = recipeComponent as Cookware 65 | expect(cookware.name).toBe(resultComponent.name) 66 | // split in logic here. For non-number quantities, the string is still in the "amount" field 67 | if(typeof cookware.quantity === 'undefined' || isNaN(cookware.quantity)) 68 | expect(cookware.amount).toBe(resultComponent.quantity) 69 | else 70 | expect(cookware.quantity).toBe(resultComponent.quantity) 71 | break; 72 | case "timer": 73 | expect(recipeComponent).toBeInstanceOf(Timer) 74 | const timer = recipeComponent as Timer 75 | expect(timer.name).toBe(resultComponent.name) 76 | expect(timer.quantity).toBe(resultComponent.quantity) 77 | expect(timer.units).toBe(resultComponent.units) 78 | break; 79 | default: 80 | expect(resultComponent.type).toMatch(/^text|ingredient|cookware|timer$/) 81 | } 82 | } 83 | } 84 | }) 85 | }); 86 | 87 | describe("custom tests", () => { 88 | test("test ingredient emoji in the middle", () => { 89 | const source = "Brush with @🥛 or @🥚" 90 | const recipe = new Recipe(source) 91 | 92 | expect(recipe.steps.length).toBe(1) 93 | const step: Step = recipe.steps[0] 94 | 95 | expect(step.line.length).toBe(4) 96 | expect(typeof step.line[0]).toBe("string") 97 | expect(step.line[0]).toEqual("Brush with ") 98 | 99 | expect(step.line[1]).toBeInstanceOf(Ingredient) 100 | const ingredient1 = step.line[1] as Ingredient 101 | expect(ingredient1.name).toBe("🥛") 102 | expect(ingredient1.quantity).toBe(1) 103 | expect(ingredient1.units).toBe("") 104 | 105 | expect(typeof step.line[2]).toBe("string") 106 | expect(step.line[2]).toEqual(" or ") 107 | 108 | expect(step.line[3]).toBeInstanceOf(Ingredient) 109 | const ingredient3 = step.line[3] as Ingredient 110 | expect(ingredient3.name).toBe("🥚") 111 | expect(ingredient3.quantity).toBe(1) 112 | expect(ingredient3.units).toBe("") 113 | }) 114 | 115 | test("test cookware emoji in the middle", () => { 116 | const source = "Serve in a #🥤 or a #🥣" 117 | const recipe = new Recipe(source) 118 | 119 | expect(recipe.steps.length).toBe(1) 120 | const step: Step = recipe.steps[0] 121 | 122 | expect(step.line.length).toBe(4) 123 | expect(typeof step.line[0]).toBe("string") 124 | expect(step.line[0]).toEqual("Serve in a ") 125 | 126 | expect(step.line[1]).toBeInstanceOf(Cookware) 127 | const cookware1 = step.line[1] as Cookware 128 | expect(cookware1.name).toBe("🥤") 129 | 130 | expect(typeof step.line[2]).toBe("string") 131 | expect(step.line[2]).toEqual(" or a ") 132 | 133 | expect(step.line[3]).toBeInstanceOf(Cookware) 134 | const cookware3 = step.line[3] as Cookware 135 | expect(cookware3.name).toBe("🥣") 136 | }) 137 | 138 | test("test blank lines get stripped", () => { 139 | const source = "Line a\n\nLine b" 140 | const recipe = new Recipe(source) 141 | 142 | expect(recipe.steps.length).toBe(2) 143 | const step1: Step = recipe.steps[0] 144 | const step2: Step = recipe.steps[1] 145 | 146 | expect(step1.line.length).toBe(1) 147 | expect(typeof step1.line[0]).toBe("string") 148 | expect(step1.line[0]).toEqual("Line a") 149 | 150 | expect(step2.line.length).toBe(1) 151 | expect(typeof step2.line[0]).toBe("string") 152 | expect(step2.line[0]).toEqual("Line b") 153 | }) 154 | 155 | test("test whitespace only lines get stripped", () => { 156 | const source = "Line a\n \t \nLine b" 157 | const recipe = new Recipe(source) 158 | 159 | expect(recipe.steps.length).toBe(2) 160 | const step1: Step = recipe.steps[0] 161 | const step2: Step = recipe.steps[1] 162 | 163 | expect(step1.line.length).toBe(1) 164 | expect(typeof step1.line[0]).toBe("string") 165 | expect(step1.line[0]).toEqual("Line a") 166 | 167 | expect(step2.line.length).toBe(1) 168 | expect(typeof step2.line[0]).toBe("string") 169 | expect(step2.line[0]).toEqual("Line b") 170 | }) 171 | 172 | test("test lone @ should not be ingredient", () => { 173 | const source = "This is not @ an ingredient" 174 | const recipe = new Recipe(source) 175 | 176 | expect(recipe.steps.length).toBe(1) 177 | const step: Step = recipe.steps[0] 178 | 179 | expect(step.line.length).toBe(1) 180 | expect(typeof step.line[0]).toBe("string") 181 | expect(step.line[0]).toEqual("This is not @ an ingredient") 182 | }) 183 | 184 | test("test @ in middle should not be ingredient", () => { 185 | const source = "ask me@example.com for more info" 186 | const recipe = new Recipe(source) 187 | 188 | expect(recipe.steps.length).toBe(1) 189 | const step: Step = recipe.steps[0] 190 | 191 | expect(step.line.length).toBe(1) 192 | expect(typeof step.line[0]).toBe("string") 193 | expect(step.line[0]).toEqual("ask me@example.com for more info") 194 | }) 195 | 196 | // test("lone # should not be cookware") 197 | test("test lone # should not be cookware", () => { 198 | const source = "This is not # cookware" 199 | const recipe = new Recipe(source) 200 | 201 | expect(recipe.steps.length).toBe(1) 202 | const step: Step = recipe.steps[0] 203 | 204 | expect(step.line.length).toBe(1) 205 | expect(typeof step.line[0]).toBe("string") 206 | expect(step.line[0]).toEqual("This is not # cookware") 207 | }) 208 | }) 209 | 210 | describe("copy constructors", () => { 211 | test("test Ingredient copy constructor", () => { 212 | const ingredient = new Ingredient("@honey{5%tsp}") 213 | ingredient.amount = "5" 214 | ingredient.name = "honey" 215 | ingredient.quantity = 5 216 | ingredient.raw = "@honey{5%tsp}" 217 | ingredient.units = "tsp" 218 | 219 | const copy = new Ingredient(ingredient) 220 | 221 | expect(copy).not.toBe(ingredient) 222 | expect(copy).toEqual(ingredient) 223 | }) 224 | 225 | test("test Cookware copy constructor", () => { 226 | const cookware = new Cookware("#pot") 227 | cookware.name = "pot" 228 | cookware.raw = "#pot" 229 | 230 | const copy = new Cookware(cookware) 231 | 232 | expect(copy).not.toBe(cookware) 233 | expect(copy).toEqual(cookware) 234 | }) 235 | 236 | test("test Timer copy constructor", () => { 237 | const timer = new Timer("~boil{5%mins}") 238 | timer.amount = "5" 239 | timer.name = "boil" 240 | timer.quantity = 5 241 | timer.raw = "~boil{5%mins}" 242 | timer.units = "mins" 243 | timer.seconds = 300 244 | 245 | const copy = new Timer(timer) 246 | 247 | expect(copy).not.toBe(timer) 248 | expect(copy).toEqual(timer) 249 | }) 250 | 251 | test("test Metadata copy constructor", () => { 252 | const metadata = new Metadata(">> url: https://example.com") 253 | metadata.key = "url" 254 | metadata.value = "https://example.com" 255 | metadata.raw = ">> url: https://example.com" 256 | 257 | const copy = new Metadata(metadata) 258 | 259 | expect(copy).not.toBe(metadata) 260 | expect(copy).toEqual(metadata) 261 | }) 262 | 263 | test("test Step copy constructor", () => { 264 | const step = new Step("raw") 265 | step.image = "image" 266 | step.line = ["raw"] 267 | 268 | const copy = new Step(step) 269 | 270 | expect(copy).not.toBe(step) 271 | expect(copy).toEqual(step) 272 | }) 273 | }) 274 | 275 | describe("other functionality", () => { 276 | test("test total time for timers", () => { 277 | const source = "~timer1{30%Seconds}\n~timer1{30%minutes}\n~timer1{1/2%Hour}" 278 | const recipe = new Recipe(source) 279 | 280 | // 30s + 30m + 0.5h 281 | // 30 + 1800 + 1800 = 3630 282 | expect(recipe.calculateTotalTime()).toBe(3630) 283 | }) 284 | }) -------------------------------------------------------------------------------- /__tests__/canonical.yaml: -------------------------------------------------------------------------------- 1 | version: 5 2 | tests: 3 | testBasicDirection: 4 | source: | 5 | Add a bit of chilli 6 | result: 7 | steps: 8 | - 9 | - type: text 10 | value: "Add a bit of chilli" 11 | metadata: {} 12 | 13 | 14 | testComments: 15 | source: | 16 | -- testing comments 17 | result: 18 | steps: [] 19 | metadata: {} 20 | 21 | 22 | testCommentsAfterIngredients: 23 | source: | 24 | @thyme{2%springs} -- testing comments 25 | and some text 26 | result: 27 | steps: 28 | - 29 | - type: ingredient 30 | name: "thyme" 31 | quantity: 2 32 | units: "springs" 33 | - type: text 34 | value: " " 35 | - 36 | - type: text 37 | value: "and some text" 38 | metadata: {} 39 | 40 | 41 | testCommentsWithIngredients: 42 | source: | 43 | -- testing comments 44 | @thyme{2%springs} 45 | result: 46 | steps: 47 | - 48 | - type: ingredient 49 | name: "thyme" 50 | quantity: 2 51 | units: "springs" 52 | metadata: {} 53 | 54 | 55 | testDirectionsWithDegrees: 56 | source: | 57 | Heat oven up to 200°C 58 | result: 59 | steps: 60 | - 61 | - type: text 62 | value: "Heat oven up to 200°C" 63 | metadata: {} 64 | 65 | 66 | testDirectionsWithNumbers: 67 | source: | 68 | Heat 5L of water 69 | result: 70 | steps: 71 | - 72 | - type: text 73 | value: "Heat 5L of water" 74 | metadata: {} 75 | 76 | 77 | testDirectionWithIngrident: 78 | source: | 79 | Add @chilli{3%items}, @ginger{10%g} and @milk{1%l}. 80 | result: 81 | steps: 82 | - 83 | - type: text 84 | value: "Add " 85 | - type: ingredient 86 | name: "chilli" 87 | quantity: 3 88 | units: "items" 89 | - type: text 90 | value: ", " 91 | - type: ingredient 92 | name: "ginger" 93 | quantity: 10 94 | units: "g" 95 | - type: text 96 | value: " and " 97 | - type: ingredient 98 | name: "milk" 99 | quantity: 1 100 | units: "l" 101 | - type: text 102 | value: "." 103 | metadata: {} 104 | 105 | 106 | testEquipmentMultipleWords: 107 | source: | 108 | Fry in #frying pan{} 109 | result: 110 | steps: 111 | - 112 | - type: text 113 | value: "Fry in " 114 | - type: cookware 115 | name: "frying pan" 116 | quantity: 1 117 | metadata: {} 118 | 119 | 120 | testEquipmentMultipleWordsWithLeadingNumber: 121 | source: | 122 | Fry in #7-inch nonstick frying pan{ } 123 | result: 124 | steps: 125 | - 126 | - type: text 127 | value: "Fry in " 128 | - type: cookware 129 | name: "7-inch nonstick frying pan" 130 | quantity: 1 131 | metadata: {} 132 | 133 | 134 | testEquipmentMultipleWordsWithSpaces: 135 | source: | 136 | Fry in #frying pan{ } 137 | result: 138 | steps: 139 | - 140 | - type: text 141 | value: "Fry in " 142 | - type: cookware 143 | name: "frying pan" 144 | quantity: 1 145 | metadata: {} 146 | 147 | 148 | testEquipmentOneWord: 149 | source: | 150 | Simmer in #pan for some time 151 | result: 152 | steps: 153 | - 154 | - type: text 155 | value: "Simmer in " 156 | - type: cookware 157 | name: "pan" 158 | quantity: 1 159 | - type: text 160 | value: " for some time" 161 | metadata: {} 162 | 163 | testEquipmentQuantity: 164 | source: | 165 | #frying pan{2} 166 | result: 167 | steps: 168 | - 169 | - type: cookware 170 | name: "frying pan" 171 | quantity: 2 172 | metadata: {} 173 | 174 | testEquipmentQuantityOneWord: 175 | source: | 176 | #frying pan{three} 177 | result: 178 | steps: 179 | - 180 | - type: cookware 181 | name: "frying pan" 182 | quantity: three 183 | metadata: {} 184 | 185 | testEquipmentQuantityMultipleWords: 186 | source: | 187 | #frying pan{two small} 188 | result: 189 | steps: 190 | - 191 | - type: cookware 192 | name: "frying pan" 193 | quantity: two small 194 | metadata: {} 195 | 196 | testFractions: 197 | source: | 198 | @milk{1/2%cup} 199 | result: 200 | steps: 201 | - 202 | - type: ingredient 203 | name: "milk" 204 | quantity: 0.5 205 | units: "cup" 206 | metadata: {} 207 | 208 | 209 | testFractionsInDirections: 210 | source: | 211 | knife cut about every 1/2 inches 212 | result: 213 | steps: 214 | - 215 | - type: text 216 | value: "knife cut about every 1/2 inches" 217 | metadata: {} 218 | 219 | 220 | testFractionsLike: 221 | source: | 222 | @milk{01/2%cup} 223 | result: 224 | steps: 225 | - 226 | - type: ingredient 227 | name: "milk" 228 | quantity: 01/2 229 | units: "cup" 230 | metadata: {} 231 | 232 | 233 | testFractionsWithSpaces: 234 | source: | 235 | @milk{1 / 2 %cup} 236 | result: 237 | steps: 238 | - 239 | - type: ingredient 240 | name: "milk" 241 | quantity: 0.5 242 | units: "cup" 243 | metadata: {} 244 | 245 | 246 | testIngredientMultipleWordsWithLeadingNumber: 247 | source: | 248 | Top with @1000 island dressing{ } 249 | result: 250 | steps: 251 | - 252 | - type: text 253 | value: "Top with " 254 | - type: ingredient 255 | name: "1000 island dressing" 256 | quantity: "some" 257 | units: "" 258 | metadata: {} 259 | 260 | 261 | testIngredientWithEmoji: 262 | source: | 263 | Add some @🧂 264 | result: 265 | steps: 266 | - 267 | - type: text 268 | value: "Add some " 269 | - type: ingredient 270 | name: "🧂" 271 | quantity: "some" 272 | units: "" 273 | metadata: {} 274 | 275 | 276 | testIngridentExplicitUnits: 277 | source: | 278 | @chilli{3%items} 279 | result: 280 | steps: 281 | - 282 | - type: ingredient 283 | name: "chilli" 284 | quantity: 3 285 | units: "items" 286 | metadata: {} 287 | 288 | 289 | testIngridentExplicitUnitsWithSpaces: 290 | source: | 291 | @chilli{ 3 % items } 292 | result: 293 | steps: 294 | - 295 | - type: ingredient 296 | name: "chilli" 297 | quantity: 3 298 | units: "items" 299 | metadata: {} 300 | 301 | 302 | testIngridentImplicitUnits: 303 | source: | 304 | @chilli{3} 305 | result: 306 | steps: 307 | - 308 | - type: ingredient 309 | name: "chilli" 310 | quantity: 3 311 | units: "" 312 | metadata: {} 313 | 314 | 315 | testIngridentNoUnits: 316 | source: | 317 | @chilli 318 | result: 319 | steps: 320 | - 321 | - type: ingredient 322 | name: "chilli" 323 | quantity: "some" 324 | units: "" 325 | metadata: {} 326 | 327 | 328 | testIngridentNoUnitsNotOnlyString: 329 | source: | 330 | @5peppers 331 | result: 332 | steps: 333 | - 334 | - type: ingredient 335 | name: "5peppers" 336 | quantity: "some" 337 | units: "" 338 | metadata: {} 339 | 340 | 341 | testIngridentWithNumbers: 342 | source: | 343 | @tipo 00 flour{250%g} 344 | result: 345 | steps: 346 | - 347 | - type: ingredient 348 | name: "tipo 00 flour" 349 | quantity: 250 350 | units: "g" 351 | metadata: {} 352 | 353 | 354 | testIngridentWithoutStopper: 355 | source: | 356 | @chilli cut into pieces 357 | result: 358 | steps: 359 | - 360 | - type: ingredient 361 | name: "chilli" 362 | quantity: "some" 363 | units: "" 364 | - type: text 365 | value: " cut into pieces" 366 | metadata: {} 367 | 368 | 369 | testMetadata: 370 | source: | 371 | >> sourced: babooshka 372 | result: 373 | steps: [] 374 | metadata: 375 | "sourced": babooshka 376 | 377 | 378 | testMetadataBreak: 379 | source: | 380 | hello >> sourced: babooshka 381 | result: 382 | steps: 383 | - 384 | - type: text 385 | value: "hello >> sourced: babooshka" 386 | metadata: {} 387 | 388 | 389 | testMetadataMultiwordKey: 390 | source: | 391 | >> cooking time: 30 mins 392 | result: 393 | steps: [] 394 | metadata: 395 | "cooking time": 30 mins 396 | 397 | 398 | testMetadataMultiwordKeyWithSpaces: 399 | source: | 400 | >>cooking time :30 mins 401 | result: 402 | steps: [] 403 | metadata: 404 | "cooking time": 30 mins 405 | 406 | 407 | testMultiLineDirections: 408 | source: | 409 | Add a bit of chilli 410 | 411 | Add a bit of hummus 412 | result: 413 | steps: 414 | - 415 | - type: text 416 | value: "Add a bit of chilli" 417 | - 418 | - type: text 419 | value: "Add a bit of hummus" 420 | metadata: {} 421 | 422 | 423 | testMultipleLines: 424 | source: | 425 | >> Prep Time: 15 minutes 426 | >> Cook Time: 30 minutes 427 | result: 428 | steps: [] 429 | metadata: 430 | "Prep Time": 15 minutes 431 | "Cook Time": 30 minutes 432 | 433 | 434 | testMultiWordIngrident: 435 | source: | 436 | @hot chilli{3} 437 | result: 438 | steps: 439 | - 440 | - type: ingredient 441 | name: "hot chilli" 442 | quantity: 3 443 | units: "" 444 | metadata: {} 445 | 446 | 447 | testMultiWordIngridentNoAmount: 448 | source: | 449 | @hot chilli{} 450 | result: 451 | steps: 452 | - 453 | - type: ingredient 454 | name: "hot chilli" 455 | quantity: "some" 456 | units: "" 457 | metadata: {} 458 | 459 | 460 | testMutipleIngridentsWithoutStopper: 461 | source: | 462 | @chilli cut into pieces and @garlic 463 | result: 464 | steps: 465 | - 466 | - type: ingredient 467 | name: "chilli" 468 | quantity: "some" 469 | units: "" 470 | - type: text 471 | value: " cut into pieces and " 472 | - type: ingredient 473 | name: "garlic" 474 | quantity: "some" 475 | units: "" 476 | metadata: {} 477 | 478 | 479 | testQuantityAsText: 480 | source: | 481 | @thyme{few%springs} 482 | result: 483 | steps: 484 | - 485 | - type: ingredient 486 | name: "thyme" 487 | quantity: few 488 | units: "springs" 489 | metadata: {} 490 | 491 | 492 | testQuantityDigitalString: 493 | source: | 494 | @water{7 k } 495 | result: 496 | steps: 497 | - 498 | - type: ingredient 499 | name: "water" 500 | quantity: 7 k 501 | units: "" 502 | metadata: {} 503 | 504 | 505 | testServings: 506 | source: | 507 | >> servings: 1|2|3 508 | result: 509 | steps: [] 510 | metadata: 511 | "servings": 1|2|3 512 | 513 | 514 | testSlashInText: 515 | source: | 516 | Preheat the oven to 200℃/Fan 180°C. 517 | result: 518 | steps: 519 | - 520 | - type: text 521 | value: "Preheat the oven to 200℃/Fan 180°C." 522 | metadata: {} 523 | 524 | 525 | testTimerDecimal: 526 | source: | 527 | Fry for ~{1.5%minutes} 528 | result: 529 | steps: 530 | - 531 | - type: text 532 | value: "Fry for " 533 | - type: timer 534 | quantity: 1.5 535 | units: "minutes" 536 | name: "" 537 | metadata: {} 538 | 539 | 540 | testTimerFractional: 541 | source: | 542 | Fry for ~{1/2%hour} 543 | result: 544 | steps: 545 | - 546 | - type: text 547 | value: "Fry for " 548 | - type: timer 549 | quantity: 0.5 550 | units: "hour" 551 | name: "" 552 | metadata: {} 553 | 554 | 555 | testTimerInteger: 556 | source: | 557 | Fry for ~{10%minutes} 558 | result: 559 | steps: 560 | - 561 | - type: text 562 | value: "Fry for " 563 | - type: timer 564 | quantity: 10 565 | units: "minutes" 566 | name: "" 567 | metadata: {} 568 | 569 | 570 | testTimerWithName: 571 | source: | 572 | Fry for ~potato{42%minutes} 573 | result: 574 | steps: 575 | - 576 | - type: text 577 | value: "Fry for " 578 | - type: timer 579 | quantity: 42 580 | units: "minutes" 581 | name: "potato" 582 | metadata: {} 583 | 584 | # TODO add common syntax errors --------------------------------------------------------------------------------