├── CHANGELOG.md ├── phpstan.neon ├── src ├── assetbundles │ ├── recipe │ │ ├── dist │ │ │ ├── css │ │ │ │ └── Recipe.css │ │ │ ├── js │ │ │ │ └── Recipe.js │ │ │ └── img │ │ │ │ └── Recipe-icon.svg │ │ └── RecipeAsset.php │ └── recipefield │ │ ├── dist │ │ ├── img │ │ │ ├── Star-icon.svg │ │ │ ├── star.svg │ │ │ ├── Recipe-icon.svg │ │ │ ├── icon.svg │ │ │ ├── Healthy-icon.svg │ │ │ └── healthy.svg │ │ ├── css │ │ │ └── Recipe.css │ │ └── js │ │ │ └── Recipe.js │ │ └── RecipeFieldAsset.php ├── templates │ ├── settings.twig │ ├── _components │ │ └── fields │ │ │ ├── Recipe_settings.twig │ │ │ └── Recipe_input.twig │ ├── welcome.twig │ ├── _integrations │ │ └── feed-me.twig │ └── recipe-nutrition-facts.twig ├── config.php ├── models │ ├── Settings.php │ └── Recipe.php ├── services │ ├── ServicesTrait.php │ └── NutritionApi.php ├── controllers │ └── NutritionApiController.php ├── icon.svg ├── translations │ └── en │ │ └── recipe.php ├── integrations │ └── RecipeFeedMeField.php ├── helpers │ ├── Json.php │ └── PluginTemplate.php ├── Recipe.php ├── console │ └── controllers │ │ └── NutritionApiController.php └── fields │ └── Recipe.php ├── ecs.php ├── Makefile ├── LICENSE.md ├── composer.json └── README.md /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Recipe Changelog 2 | 3 | ## 5.0.0 - 2024.10.08 4 | ### Added 5 | * Initial Craft CMS 5 release 6 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - %currentWorkingDirectory%/vendor/craftcms/phpstan/phpstan.neon 3 | 4 | parameters: 5 | level: 5 6 | paths: 7 | - src 8 | -------------------------------------------------------------------------------- /src/assetbundles/recipe/dist/css/Recipe.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Recipe plugin for Craft CMS 3 | * 4 | * Recipe CSS 5 | * 6 | * @author nystudio107 7 | * @copyright Copyright (c) 2017 nystudio107 8 | * @link https://nystudio107.com 9 | * @package Recipe 10 | * @since 1.0.0 11 | */ 12 | -------------------------------------------------------------------------------- /src/assetbundles/recipe/dist/js/Recipe.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Recipe plugin for Craft CMS 3 | * 4 | * Recipe JS 5 | * 6 | * @author nystudio107 7 | * @copyright Copyright (c) 2017 nystudio107 8 | * @link https://nystudio107.com 9 | * @package Recipe 10 | * @since 1.0.0 11 | */ 12 | -------------------------------------------------------------------------------- /src/assetbundles/recipefield/dist/img/Star-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths([ 8 | __DIR__ . '/src', 9 | __FILE__, 10 | ]); 11 | $ecsConfig->parallel(); 12 | $ecsConfig->sets([SetList::CRAFT_CMS_4]); 13 | }; 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAJOR_VERSION?=5 2 | PLUGINDEV_PROJECT_DIR?=/Users/andrew/webdev/sites/plugindev/cms_v${MAJOR_VERSION}/ 3 | VENDOR?=nystudio107 4 | PROJECT_PATH?=${VENDOR}/$(shell basename $(CURDIR)) 5 | 6 | .PHONY: dev docs release 7 | 8 | # Start up the buildchain dev server 9 | dev: 10 | # Start up the docs dev server 11 | docs: 12 | ${MAKE} -C docs/ dev 13 | # Run code quality tools, tests, and build the buildchain & docs in preparation for a release 14 | release: --code-quality --code-tests --buildchain-clean-build --docs-clean-build 15 | # The internal targets used by the dev & release targets 16 | --buildchain-clean-build: 17 | --code-quality: 18 | ${MAKE} -C ${PLUGINDEV_PROJECT_DIR} -- ecs check vendor/${PROJECT_PATH}/src --fix 19 | ${MAKE} -C ${PLUGINDEV_PROJECT_DIR} -- phpstan analyze -c vendor/${PROJECT_PATH}/phpstan.neon 20 | --code-tests: 21 | --docs-clean-build: 22 | ${MAKE} -C docs/ clean 23 | ${MAKE} -C docs/ image-build 24 | ${MAKE} -C docs/ fix 25 | -------------------------------------------------------------------------------- /src/templates/settings.twig: -------------------------------------------------------------------------------- 1 | {% import '_includes/forms' as forms %} 2 | 3 | 4 |

5 | {{ 'The [Edamam Nutrition Analysis API]({url}) can be used to fetch nutritional information from your ingredients.'|t('recipe', { url: 'https://developer.edamam.com/edamam-nutrition-api' })|md }} 6 |

7 | 8 |
9 | 10 | {{ forms.autosuggestField({ 11 | label: 'API Application ID'|t('blitz'), 12 | instructions: 'An application ID for the Edamam Nutrition Analysis API.'|t('recipe'), 13 | suggestEnvVars: true, 14 | name: 'apiApplicationId', 15 | value: settings.apiApplicationId, 16 | errors: settings.getErrors('apiApplicationId'), 17 | first: true, 18 | }) }} 19 | 20 | {{ forms.autosuggestField({ 21 | label: 'API Application Key'|t('recipe'), 22 | instructions: 'An application ID for the Edamam Nutrition Analysis API.'|t('recipe'), 23 | suggestEnvVars: true, 24 | name: 'apiApplicationKey', 25 | value: settings.apiApplicationKey, 26 | errors: settings.getErrors('apiApplicationKey'), 27 | }) }} 28 | -------------------------------------------------------------------------------- /src/config.php: -------------------------------------------------------------------------------- 1 | [ 22 | // An application ID for the Edamam Nutrition Analysis API (https://developer.edamam.com/edamam-nutrition-api). 23 | //'apiApplicationId' => '1a2b3c4e', 24 | 25 | // An application key for the Edamam Nutrition Analysis API (https://developer.edamam.com/edamam-nutrition-api). 26 | //'apiApplicationId' => '1a2b3c4e5f6g7h8i9j0k', 27 | ], 28 | ]; 29 | -------------------------------------------------------------------------------- /src/templates/_components/fields/Recipe_settings.twig: -------------------------------------------------------------------------------- 1 | {# 2 | /** 3 | * Recipe plugin for Craft CMS 4 | * 5 | * Recipe Field Settings 6 | * 7 | * @author nystudio107 8 | * @copyright Copyright (c) 2017 nystudio107 9 | * @link https://nystudio107.com 10 | * @package Recipe 11 | * @since 1.0.0 12 | */ 13 | #} 14 | 15 | {% import "_includes/forms" as forms %} 16 | 17 | {% do view.registerAssetBundle("nystudio107\\recipe\\assetbundles\\recipe\\RecipeAsset") %} 18 | 19 |
20 | {% if assetSources %} 21 | {{ forms.checkboxSelectField({ 22 | label: "Asset Sources"|t, 23 | instructions: "Which sources do you want to select assets from?"|t, 24 | id: 'assetSources', 25 | name: 'assetSources', 26 | options: assetSources, 27 | values: field['assetSources'] 28 | }) }} 29 | {% else %} 30 | {{ forms.field({ 31 | label: "Asset Sources"|t, 32 | }, '

' ~ "No asset sources exist yet."|t ~ '

') }} 33 | {% endif %} 34 | 35 |
-------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) nystudio107 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /src/assetbundles/recipe/RecipeAsset.php: -------------------------------------------------------------------------------- 1 | sourcePath = "@nystudio107/recipe/assetbundles/recipe/dist"; 33 | 34 | $this->depends = [ 35 | CpAsset::class, 36 | ]; 37 | 38 | $this->js = [ 39 | 'js/Recipe.js', 40 | ]; 41 | 42 | $this->css = [ 43 | 'css/Recipe.css', 44 | ]; 45 | 46 | parent::init(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/assetbundles/recipefield/RecipeFieldAsset.php: -------------------------------------------------------------------------------- 1 | sourcePath = "@nystudio107/recipe/assetbundles/recipefield/dist"; 33 | 34 | $this->depends = [ 35 | CpAsset::class, 36 | ]; 37 | 38 | $this->js = [ 39 | 'js/Recipe.js', 40 | ]; 41 | 42 | $this->css = [ 43 | 'css/Recipe.css', 44 | ]; 45 | 46 | parent::init(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/models/Settings.php: -------------------------------------------------------------------------------- 1 | apiApplicationId && $this->apiApplicationKey); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/services/ServicesTrait.php: -------------------------------------------------------------------------------- 1 | [ 35 | 'nutritionApi' => NutritionApi::class, 36 | ], 37 | ]; 38 | } 39 | 40 | // Public Methods 41 | // ========================================================================= 42 | 43 | /** 44 | * Returns the nutritionApi service 45 | * 46 | * @return NutritionApi The nutritionApi service 47 | * @throws InvalidConfigException 48 | */ 49 | public function getHelper(): NutritionApi 50 | { 51 | return $this->get('nutritionApi'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/controllers/NutritionApiController.php: -------------------------------------------------------------------------------- 1 | requireAcceptsJson(); 35 | 36 | $ingredients = Craft::$app->getRequest()->getParam('ingredients'); 37 | $serves = Craft::$app->getRequest()->getParam('serves'); 38 | 39 | if (empty($ingredients)) { 40 | return $this->asJson([ 41 | 'error' => 'Please provide some ingredients first.', 42 | ]); 43 | } 44 | 45 | $nutritionalInfo = Recipe::$plugin->nutritionApi->getNutritionalInfo($ingredients, $serves); 46 | 47 | return $this->asJson($nutritionalInfo); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/assetbundles/recipefield/dist/img/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nystudio107/craft-recipe", 3 | "description": "A comprehensive recipe FieldType for Craft CMS that includes metric/imperial conversion, portion calculation, and JSON-LD microdata support", 4 | "type": "craft-plugin", 5 | "version": "5.0.0", 6 | "keywords": [ 7 | "craft", 8 | "cms", 9 | "craftcms", 10 | "craft-plugin", 11 | "recipe" 12 | ], 13 | "support": { 14 | "docs": "https://nystudio107.com/docs/recipe/", 15 | "issues": "https://nystudio107.com/plugins/recipe/support", 16 | "source": "https://github.com/nystudio107/craft-recipe" 17 | }, 18 | "license": "MIT", 19 | "authors": [ 20 | { 21 | "name": "nystudio107", 22 | "homepage": "https://nystudio107.com" 23 | } 24 | ], 25 | "require": { 26 | "craftcms/cms": "^5.0.0" 27 | }, 28 | "require-dev": { 29 | "craftcms/ecs": "dev-main", 30 | "craftcms/feed-me": "^6.0.0", 31 | "craftcms/phpstan": "dev-main", 32 | "craftcms/rector": "dev-main", 33 | "nystudio107/craft-seomatic": "^5.0.0" 34 | }, 35 | "scripts": { 36 | "phpstan": "phpstan --ansi --memory-limit=1G", 37 | "check-cs": "ecs check --ansi", 38 | "fix-cs": "ecs check --fix --ansi" 39 | }, 40 | "config": { 41 | "allow-plugins": { 42 | "craftcms/plugin-installer": true, 43 | "yiisoft/yii2-composer": true 44 | }, 45 | "optimize-autoloader": true, 46 | "sort-packages": true 47 | }, 48 | "autoload": { 49 | "psr-4": { 50 | "nystudio107\\recipe\\": "src/" 51 | } 52 | }, 53 | "extra": { 54 | "class": "nystudio107\\recipe\\Recipe", 55 | "handle": "recipe", 56 | "name": "Recipe" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/nystudio107/craft-recipe/badges/quality-score.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-recipe/?branch=v5) [![Code Coverage](https://scrutinizer-ci.com/g/nystudio107/craft-recipe/badges/coverage.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-recipe/?branch=v5) [![Build Status](https://scrutinizer-ci.com/g/nystudio107/craft-recipe/badges/build.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-recipe/build-status/v5) [![Code Intelligence Status](https://scrutinizer-ci.com/g/nystudio107/craft-recipe/badges/code-intelligence.svg?b=v5)](https://scrutinizer-ci.com/code-intelligence) 2 | 3 | # Recipe plugin for Craft CMS 5.x 4 | 5 | A comprehensive recipe FieldType for Craft CMS that includes metric/imperial conversion, portion calculation, and JSON-LD microdata support 6 | 7 | ![Screenshot](./docs/docs/resources/img/plugin-logo.png) 8 | 9 | ## Requirements 10 | 11 | This plugin requires Craft CMS 5.0.0 or later. 12 | 13 | ## Installation 14 | 15 | To install Recipe, follow these steps: 16 | 17 | 1. Install with Composer via `composer require nystudio107/craft-recipe` 18 | 2. Install the plugin via `./craft install/plugin recipe` via the CLI, or in the Control Panel, go to Settings → Plugins and click the “Install” button for Recipe. 19 | 20 | You can also install Recipe via the **Plugin Store** in the Craft AdminCP. 21 | 22 | ## Documentation 23 | 24 | Click here -> [Recipe Documentation](https://nystudio107.com/plugins/recipe/documentation) 25 | 26 | ## Recipe Roadmap 27 | 28 | Some things to do, and ideas for potential features: 29 | 30 | * Provide a front-end way to add ratings 31 | 32 | Brought to you by [nystudio107](https://nystudio107.com) 33 | -------------------------------------------------------------------------------- /src/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 11 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/assetbundles/recipe/dist/img/Recipe-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 11 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/assetbundles/recipefield/dist/img/Recipe-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 11 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/translations/en/recipe.php: -------------------------------------------------------------------------------- 1 | 'Recipe', 19 | 'Review' => 'Review', 20 | '{name} plugin loaded' => '{name} plugin loaded', 21 | 'Error rendering template string -> {error}' => 'Error rendering template string -> {error}', 22 | 'Error rendering `{template}` -> {error}' => 'Error rendering `{template}` -> {error}', 23 | 'API Application Key' => 'API Application Key', 24 | 'Failed to generate nutritional information for {count} entries.' => 'Failed to generate nutritional information for {count} entries.', 25 | 'API credentials do not exist in plugin settings.' => 'API credentials do not exist in plugin settings.', 26 | 'An application ID for the Edamam Nutrition Analysis API.' => 'An application ID for the Edamam Nutrition Analysis API.', 27 | 'No entries found in the section with handle `{handle}`.' => 'No entries found in the section with handle `{handle}`.', 28 | 'A section handle must be provided using --section.' => 'A section handle must be provided using --section.', 29 | 'Generating nutritional information for {count} entries...' => 'Generating nutritional information for {count} entries...', 30 | 'The [Edamam Nutrition Analysis API]({url}) can be used to fetch nutritional information from your ingredients.' => 'The [Edamam Nutrition Analysis API]({url}) can be used to fetch nutritional information from your ingredients.', 31 | 'A field handle must be provided using --field.' => 'A field handle must be provided using --field.', 32 | 'Successfully generated nutritional information for {count} entries.' => 'Successfully generated nutritional information for {count} entries.', 33 | ]; 34 | -------------------------------------------------------------------------------- /src/assetbundles/recipefield/dist/img/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 21 | 22 | -------------------------------------------------------------------------------- /src/templates/welcome.twig: -------------------------------------------------------------------------------- 1 | {% extends '_layouts/cp' %} 2 | {% set title = 'Welcome to Recipe!' %} 3 | 4 | {% set linkGetStarted = url('settings/fields') %} 5 | {% set docsUrl = "https://github.com/nystudio107/craft-recipe/blob/master/README.md" %} 6 | 7 | {% do view.registerAssetBundle("nystudio107\\recipe\\assetbundles\\recipe\\RecipeAsset") %} 8 | {% set baseAssetsUrl = view.getAssetManager().getPublishedUrl('@nystudio107/recipe/assetbundles/recipe/dist', true) %} 9 | 10 | {% set crumbs = [ 11 | { label: "Recipe", url: url('recipe') }, 12 | { label: "Welcome"|t, url: url('recipe/welcome') }, 13 | ] %} 14 | 15 | {% set content %} 16 |
17 | 18 |

Thanks for using Recipe!

19 |

Recipe adds a 'Recipe' FieldType for Craft CMS that you can add to any of your Sections.

20 |

In encapsulates everything you need for a recipe, including the ingredients, a photo of the recipe, directions, cooking time, ratings, and even nutritional information. It handles converting between Imperial and Metric units, outputs 'pretty' fractions for Imperial units, and can output correct ingredient portions for any number of servings.

21 |

Recipe also generates the JSON-LD microdata for your recipes, which allows it to be displayed in the Googleknowledge panel for search results.

22 |

We hope Recipe makes it easier for you to create and share some yummy recipes!

23 |

24 |   25 |

26 |

27 | 28 | 29 | 30 |

31 |
32 |
33 |

34 | Brought to you by nystudio107 35 |

36 |
37 | {% endset %} 38 | -------------------------------------------------------------------------------- /src/assetbundles/recipefield/dist/img/Healthy-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assetbundles/recipefield/dist/img/healthy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 17 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/integrations/RecipeFeedMeField.php: -------------------------------------------------------------------------------- 1 | fieldInfo, 'fields'); 52 | 53 | if (!$fields) { 54 | return null; 55 | } 56 | 57 | foreach ($fields as $subFieldHandle => $subFieldInfo) { 58 | // Check for sub-sub fields - bit dirty... 59 | $subfields = Hash::get($subFieldInfo, 'fields'); 60 | 61 | if ($subfields) { 62 | foreach ($subfields as $subSubFieldHandle => $subSubFieldInfo) { 63 | // Handle array data, man I hate Feed Me's data mapping now... 64 | $content = DataHelper::fetchArrayValue($this->feedData, $subSubFieldInfo); 65 | 66 | if (is_array($content)) { 67 | foreach ($content as $key => $value) { 68 | $preppedData[$subFieldHandle][$key][$subSubFieldHandle] = $value; 69 | } 70 | } else { 71 | $preppedData[$subFieldHandle][$subSubFieldHandle] = $content; 72 | } 73 | } 74 | } else { 75 | $preppedData[$subFieldHandle] = DataHelper::fetchValue($this->feedData, $subFieldInfo); 76 | } 77 | } 78 | 79 | // Protect against sending an empty array 80 | if (!$preppedData) { 81 | return null; 82 | } 83 | 84 | return $preppedData; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/helpers/Json.php: -------------------------------------------------------------------------------- 1 | getConfig()->getGeneral()->devMode) { 42 | $options |= JSON_PRETTY_PRINT; 43 | } 44 | 45 | self::$recursionLevel = 0; 46 | 47 | return parent::encode($value, $options); 48 | } 49 | 50 | /** 51 | * @inheritdoc 52 | */ 53 | protected static function processData($data, &$expressions, $expPrefix) 54 | { 55 | ++self::$recursionLevel; 56 | $result = parent::processData($data, $expressions, $expPrefix); 57 | --self::$recursionLevel; 58 | static::normalizeJsonLdArray($result, self::$recursionLevel); 59 | 60 | return $result; 61 | } 62 | 63 | 64 | // Private Methods 65 | // ========================================================================= 66 | 67 | /** 68 | * Normalize the JSON-LD array recursively to remove empty values, change 69 | * 'type' to '@type' and have it be the first item in the array 70 | * 71 | * @param $array 72 | * @param $depth 73 | */ 74 | protected static function normalizeJsonLdArray(&$array, $depth): void 75 | { 76 | $array = array_filter($array); 77 | $array = self::changeKey($array, 'context', '@context'); 78 | $array = self::changeKey($array, 'type', '@type'); 79 | ksort($array); 80 | } 81 | 82 | /** 83 | * Replace key values without reordering the array or converting numeric 84 | * keys to associative keys (which unset() does) 85 | */ 86 | protected static function changeKey(array $array, string $oldKey, string $newKey): array 87 | { 88 | if (!array_key_exists($oldKey, $array)) { 89 | return $array; 90 | } 91 | 92 | $keys = array_keys($array); 93 | $keys[array_search($oldKey, $keys)] = $newKey; 94 | 95 | return array_combine($keys, $array); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/assetbundles/recipefield/dist/css/Recipe.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Recipe plugin for Craft CMS 3 | * 4 | * Recipe Field CSS 5 | * 6 | * @author nystudio107 7 | * @copyright Copyright (c) 2017 nystudio107 8 | * @link https://nystudio107.com 9 | * @package Recipe 10 | * @since 1.0.0 11 | */ 12 | 13 | div.recipe-field { 14 | border: 1px solid #e3e5e8; 15 | margin-bottom: 10px; 16 | border-radius: 3px; 17 | background: #f9fafa; 18 | } 19 | 20 | div.recipe-field-title { 21 | background: #eef0f1; 22 | color: #8f98a3; 23 | margin: -7px -14px 14px; 24 | padding: 7px 14px 7px 14px; 25 | width: calc(100% + 28px); 26 | -webkit-box-sizing: border-box; 27 | -moz-box-sizing: border-box; 28 | box-sizing: border-box; 29 | border-radius: 2px 2px 0 0; 30 | overflow: hidden; 31 | white-space: nowrap; 32 | text-overflow: ellipsis; 33 | word-wrap: normal; 34 | cursor: default; 35 | } 36 | 37 | img.recipe-field-icon { 38 | display: inline-block; 39 | width: 16px; 40 | height: auto; 41 | padding-right: 7px; 42 | vertical-align: middle; 43 | } 44 | 45 | span.recipe-field-title { 46 | vertical-align: middle; 47 | } 48 | 49 | 50 | div.recipe-tab-content { 51 | -webkit-box-flex: 1; 52 | -ms-flex: 1; 53 | flex: 1; 54 | background: #fff; 55 | padding: 24px; 56 | word-wrap: break-word; 57 | } 58 | 59 | nav.recipe-tabs { 60 | position: relative; 61 | z-index: 1; 62 | -webkit-box-shadow: inset 0 -1px 0 #e3e5e8; 63 | box-shadow: inset 0 -1px 0 #e3e5e8; 64 | min-height: 40px; 65 | overflow: hidden; 66 | } 67 | 68 | nav.recipe-tabs ul { 69 | display: -webkit-box; 70 | display: -ms-flexbox; 71 | display: flex; 72 | -webkit-box-orient: horizontal; 73 | -webkit-box-direction: normal; 74 | -ms-flex-direction: row; 75 | flex-direction: row; 76 | width: calc(100% + 1px); 77 | } 78 | 79 | nav.recipe-tabs ul li { 80 | -webkit-box-sizing: border-box; 81 | box-sizing: border-box; 82 | border-right: 1px solid rgba(0, 0, 20, 0.1); 83 | } 84 | 85 | nav.recipe-tabs ul li a { 86 | position: relative; 87 | display: block; 88 | padding: 10px 24px; 89 | white-space: nowrap; 90 | overflow: hidden; 91 | color: #576575; 92 | text-decoration: none; 93 | } 94 | 95 | nav.recipe-tabs ul li a:active, nav.recipe-tabs ul li a:focus { 96 | outline: 0; 97 | border: none; 98 | -moz-outline-style: none; 99 | } 100 | 101 | nav.recipe-tabs ul li a.sel { 102 | color: #29323d; 103 | background: #fff; 104 | padding-bottom: 10px; 105 | cursor: default; 106 | } 107 | 108 | nav.recipe-tabs ul li a:not(.sel):hover { 109 | color: #0d78f2; 110 | } 111 | 112 | nav.recipe-tabs ul li a:after { 113 | background: linear-gradient(to right, rgba(235, 237, 239, 0), #ebedef 17px); 114 | } 115 | 116 | nav.recipe-tabs ul li a.sel:after { 117 | background: linear-gradient(to right, rgba(255, 255, 255, 0), #fff 17px); 118 | } 119 | 120 | div.content-icon-wrapper { 121 | height: 24px; 122 | width: auto; 123 | display: inline-block; 124 | padding: 3px 10px; 125 | cursor: help; 126 | } 127 | 128 | fieldset.recipe-fields .status-badge { 129 | display: none; 130 | } 131 | -------------------------------------------------------------------------------- /src/services/NutritionApi.php: -------------------------------------------------------------------------------- 1 | getSettings(); 36 | if (!$settings->hasApiCredentials()) { 37 | return []; 38 | } 39 | 40 | $url = 'https://api.edamam.com/api/nutrition-details' 41 | . '?app_id=' . Craft::parseEnv($settings->apiApplicationId) 42 | . '&app_key=' . Craft::parseEnv($settings->apiApplicationKey); 43 | 44 | $data = [ 45 | 'ingr' => $ingredients, 46 | ]; 47 | 48 | if ($serves) { 49 | $data['yield'] = $serves; 50 | } 51 | 52 | try { 53 | $response = Craft::createGuzzleClient()->post($url, ['json' => $data]); 54 | 55 | $result = json_decode($response->getBody(), null, 512, JSON_THROW_ON_ERROR); 56 | 57 | $yield = $result->yield ?: 1; 58 | 59 | return [ 60 | 'servingSize' => round($result->totalWeight ?? 0 / $yield, 0) . ' grams', 61 | 'calories' => round($result->totalNutrients->ENERC_KCAL->quantity ?? 0 / $yield, 0), 62 | 'carbohydrateContent' => round($result->totalNutrients->CHOCDF->quantity ?? 0 / $yield, 1), 63 | 'cholesterolContent' => round($result->totalNutrients->CHOLE->quantity ?? 0 / $yield, 1), 64 | 'fatContent' => round($result->totalNutrients->FAT->quantity ?? 0 / $yield, 1), 65 | 'fiberContent' => round($result->totalNutrients->FIBTG->quantity ?? 0 / $yield, 1), 66 | 'proteinContent' => round($result->totalNutrients->PROCNT->quantity ?? 0 / $yield, 1), 67 | 'saturatedFatContent' => round($result->totalNutrients->FASAT->quantity ?? 0 / $yield, 1), 68 | 'sodiumContent' => round($result->totalNutrients->NA->quantity ?? 0 / $yield, 1), 69 | 'sugarContent' => round($result->totalNutrients->SUGAR->quantity ?? 0 / $yield, 1), 70 | 'transFatContent' => round($result->totalNutrients->FATRN->quantity ?? 0 / $yield, 1), 71 | 'unsaturatedFatContent' => round((($result->totalNutrients->FAMS->quantity ?? 0) + ($result->totalNutrients->FAPU->quantity ?? 0)) / $yield, 1), 72 | ]; 73 | } catch (Exception $exception) { 74 | $message = 'Error fetching nutritional information from API. '; 75 | 76 | if ($exception->getCode() == 401) { 77 | $message .= 'Please verify your API credentials.'; 78 | } elseif ($exception->getCode() == 555) { 79 | $message .= 'One or more ingredients could not be recognized.'; 80 | } 81 | 82 | Craft::error($message . $exception->getMessage(), __METHOD__); 83 | 84 | return ['error' => $message]; 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/assetbundles/recipefield/dist/js/Recipe.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Recipe plugin for Craft CMS 3 | * 4 | * Recipe Field JS 5 | * 6 | * @author nystudio107 7 | * @copyright Copyright (c) 2017 nystudio107 8 | * @link https://nystudio107.com 9 | * @package Recipe 10 | * @since 1.0.0 11 | */ 12 | 13 | ;(function ($, window, document, undefined) { 14 | 15 | var pluginName = "RecipeRecipe", 16 | defaults = {}; 17 | 18 | // Plugin constructor 19 | function Plugin(element, options) { 20 | this.element = element; 21 | 22 | this.options = $.extend({}, defaults, options); 23 | 24 | this._defaults = defaults; 25 | this._name = pluginName; 26 | 27 | this.init(); 28 | } 29 | 30 | Plugin.prototype = { 31 | 32 | init: function (id) { 33 | var _this = this; 34 | 35 | $(function () { 36 | 37 | /* -- _this.options gives us access to the $jsonVars that our FieldType passed down to us */ 38 | 39 | // Tab handler 40 | $('.recipe-tab-links').on('click', function (e) { 41 | e.preventDefault(); 42 | $('.recipe-tab-links').removeClass('sel'); 43 | $(this).addClass('sel'); 44 | $('.recipe-tab-content').addClass('hidden'); 45 | var selector = $(this).attr('href'); 46 | $(selector).removeClass('hidden'); 47 | // Trigger a resize to make event handlers in Garnish activate 48 | Garnish.$win.trigger('resize'); 49 | // Fixes Redactor fixed toolbars on previously hidden panes 50 | Garnish.$doc.trigger('scroll'); 51 | }); 52 | 53 | // Fetch nutritional info handler 54 | $('.fetch-nutritional-info button').on('click', function (e) { 55 | e.preventDefault(); 56 | if ($(this).hasClass('disabled')) { 57 | return; 58 | } 59 | var ingredients = []; 60 | 61 | var field = $(this).attr('data-field'); 62 | $('#' + field + 'ingredients tbody tr').each(function () { 63 | var ingredient = []; 64 | $(this).find('textarea, select').each(function () { 65 | ingredient.push($(this).val()); 66 | }) 67 | ingredients.push(ingredient.join(' ')); 68 | }); 69 | 70 | var serves = $('#' + field + 'serves').val(); 71 | 72 | $('.fetch-nutritional-info button').addClass('disabled'); 73 | $('.fetch-nutritional-info .spinner').removeClass('hidden'); 74 | 75 | Craft.postActionRequest('recipe/nutrition-api/get-nutritional-info', 76 | { 77 | ingredients: ingredients, 78 | serves: serves, 79 | }, 80 | function (response) { 81 | if (typeof response.error !== 'undefined') { 82 | Craft.cp.displayError(response.error); 83 | } else { 84 | $.each(response, function (index, value) { 85 | $('#' + field + index).val(value); 86 | }); 87 | } 88 | 89 | $('.fetch-nutritional-info button').removeClass('disabled'); 90 | $('.fetch-nutritional-info .spinner').addClass('hidden'); 91 | } 92 | ); 93 | }); 94 | }); 95 | } 96 | }; 97 | 98 | // A really lightweight plugin wrapper around the constructor, 99 | // preventing against multiple instantiations 100 | $.fn[pluginName] = function (options) { 101 | return this.each(function () { 102 | if (!$.data(this, "plugin_" + pluginName)) { 103 | $.data(this, "plugin_" + pluginName, 104 | new Plugin(this, options)); 105 | } 106 | }); 107 | }; 108 | 109 | })(jQuery, window, document); 110 | -------------------------------------------------------------------------------- /src/helpers/PluginTemplate.php: -------------------------------------------------------------------------------- 1 | getView()->renderString($templateString, $params); 34 | } catch (\Exception $exception) { 35 | $html = Craft::t( 36 | 'recipe', 37 | 'Error rendering template string -> {error}', 38 | ['error' => $exception->getMessage()] 39 | ); 40 | Craft::error($html, __METHOD__); 41 | } 42 | 43 | return $html; 44 | } 45 | 46 | /** 47 | * Render a plugin template 48 | * 49 | * @param string $templatePath 50 | * @param array $params 51 | * @return Markup 52 | */ 53 | public static function renderPluginTemplate(string $templatePath, array $params = []): Markup 54 | { 55 | $exception = null; 56 | $htmlText = ''; 57 | // Stash the old template mode, and set it Control Panel template mode 58 | $oldMode = Craft::$app->view->getTemplateMode(); 59 | // Look for a frontend template to render first 60 | try { 61 | Craft::$app->view->setTemplateMode(View::TEMPLATE_MODE_SITE); 62 | } catch (Exception $exception) { 63 | Craft::error($exception->getMessage(), __METHOD__); 64 | } 65 | 66 | // Render the template with our vars passed in 67 | try { 68 | $htmlText = Craft::$app->view->renderTemplate('recipe/' . $templatePath, $params); 69 | $templateRendered = true; 70 | } catch (\Exception $exception) { 71 | $templateRendered = false; 72 | } 73 | 74 | // If no frontend template was found, try our built-in template 75 | if (!$templateRendered) { 76 | try { 77 | Craft::$app->view->setTemplateMode(View::TEMPLATE_MODE_CP); 78 | } catch (Exception $exception) { 79 | Craft::error($exception->getMessage(), __METHOD__); 80 | } 81 | 82 | // Render the template with our vars passed in 83 | try { 84 | $htmlText = Craft::$app->view->renderTemplate('recipe/' . $templatePath, $params); 85 | $templateRendered = true; 86 | } catch (\Exception $exception) { 87 | $templateRendered = false; 88 | } 89 | } 90 | 91 | // Only if the template didn't render at all should we log an error 92 | if (!$templateRendered) { 93 | $htmlText = Craft::t( 94 | 'recipe', 95 | 'Error rendering `{template}` -> {error}', 96 | ['template' => $templatePath, 'error' => $exception->getMessage()] 97 | ); 98 | Craft::error($htmlText, __METHOD__); 99 | } 100 | 101 | // Restore the old template mode 102 | try { 103 | Craft::$app->view->setTemplateMode($oldMode); 104 | } catch (Exception $exception) { 105 | Craft::error($exception->getMessage(), __METHOD__); 106 | } 107 | 108 | return Template::raw($htmlText); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Recipe.php: -------------------------------------------------------------------------------- 1 | types[] = RecipeField::class; 88 | } 89 | ); 90 | 91 | // Show our "Welcome to Recipe" message 92 | Event::on( 93 | Plugins::class, 94 | Plugins::EVENT_AFTER_INSTALL_PLUGIN, 95 | function(PluginEvent $event): void { 96 | if (($event->plugin === $this) && !Craft::$app->getRequest()->getIsConsoleRequest()) { 97 | Craft::$app->getResponse()->redirect(UrlHelper::cpUrl('recipe/welcome'))->send(); 98 | } 99 | } 100 | ); 101 | 102 | $feedMeInstalled = Craft::$app->getPlugins()->isPluginInstalled('feed-me') && Craft::$app->getPlugins()->isPluginEnabled('feed-me'); 103 | 104 | if ($feedMeInstalled) { 105 | Event::on(FeedMeFields::class, FeedMeFields::EVENT_REGISTER_FEED_ME_FIELDS, function(RegisterFeedMeFieldsEvent $e) { 106 | $e->fields[] = RecipeFeedMeField::class; 107 | }); 108 | } 109 | 110 | Craft::info( 111 | Craft::t( 112 | 'recipe', 113 | '{name} plugin loaded', 114 | ['name' => $this->name] 115 | ), 116 | __METHOD__ 117 | ); 118 | } 119 | 120 | /** 121 | * @inheritdoc 122 | */ 123 | protected function createSettingsModel(): Settings 124 | { 125 | return new Settings(); 126 | } 127 | 128 | /** 129 | * @inheritdoc 130 | */ 131 | protected function settingsHtml(): ?string 132 | { 133 | return Craft::$app->getView()->renderTemplate('recipe/settings', [ 134 | 'settings' => $this->settings, 135 | ]); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/console/controllers/NutritionApiController.php: -------------------------------------------------------------------------------- 1 | getSettings(); 59 | if (!$settings->hasApiCredentials()) { 60 | $this->stderr(Craft::t('recipe', 'API credentials do not exist in plugin settings.') . PHP_EOL, BaseConsole::FG_RED); 61 | 62 | return ExitCode::OK; 63 | } 64 | 65 | if ($this->section === null) { 66 | $this->stderr(Craft::t('recipe', 'A section handle must be provided using --section.') . PHP_EOL, BaseConsole::FG_RED); 67 | 68 | return ExitCode::OK; 69 | } 70 | 71 | if ($this->field === null) { 72 | $this->stderr(Craft::t('recipe', 'A field handle must be provided using --field.') . PHP_EOL, BaseConsole::FG_RED); 73 | 74 | return ExitCode::OK; 75 | } 76 | 77 | $entries = Entry::find()->section($this->section)->all(); 78 | 79 | if (empty($entries)) { 80 | $this->stderr(Craft::t('recipe', 'No entries found in the section with handle `{handle}`.', ['handle' => $this->section]) . PHP_EOL, BaseConsole::FG_RED); 81 | 82 | return ExitCode::OK; 83 | } 84 | 85 | $total = count($entries); 86 | $count = 0; 87 | $failed = 0; 88 | 89 | $this->stdout(Craft::t('recipe', 'Generating nutritional information for {count} entries...', ['count' => $total]) . PHP_EOL, BaseConsole::FG_YELLOW); 90 | 91 | Console::startProgress($count, $total, '', 0.8); 92 | 93 | foreach ($entries as $entry) { 94 | $field = $entry->{$this->field}; 95 | $ingredients = $field->ingredients; 96 | 97 | foreach ($ingredients as $key => $value) { 98 | $ingredients[$key] = implode(' ', $value); 99 | } 100 | 101 | $nutritionalInfo = Recipe::$plugin->nutritionApi->getNutritionalInfo($ingredients, $field->serves); 102 | 103 | if (empty($nutritionalInfo['error'])) { 104 | $recipe = $entry->{$this->field}; 105 | 106 | foreach ($nutritionalInfo as $fieldHandle => $value) { 107 | $recipe[$fieldHandle] = $value; 108 | } 109 | 110 | $entry->setFieldValue($this->field, $recipe); 111 | 112 | if (!Craft::$app->getElements()->saveElement($entry)) { 113 | ++$failed; 114 | } 115 | } else { 116 | ++$failed; 117 | } 118 | 119 | ++$count; 120 | 121 | Console::updateProgress($count, $total); 122 | } 123 | 124 | Console::endProgress(); 125 | 126 | $succeeded = $count - $failed; 127 | 128 | $this->stdout(Craft::t('recipe', 'Successfully generated nutritional information for {count} entries.', ['count' => $succeeded]) . PHP_EOL, BaseConsole::FG_GREEN); 129 | 130 | if ($failed > 0) { 131 | $this->stderr(Craft::t('recipe', 'Failed to generate nutritional information for {count} entries.', ['count' => $failed]) . PHP_EOL, BaseConsole::FG_RED); 132 | } 133 | 134 | return ExitCode::OK; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/fields/Recipe.php: -------------------------------------------------------------------------------- 1 | getView()->renderTemplate( 142 | 'recipe/_components/fields/Recipe_settings', 143 | [ 144 | 'field' => $this, 145 | 'assetSources' => $this->getSourceOptions(), 146 | ] 147 | ); 148 | } 149 | 150 | /** 151 | * @inheritdoc 152 | */ 153 | public function getInputHtml(mixed $value, ?ElementInterface $element = null): string 154 | { 155 | // Register our asset bundle 156 | try { 157 | Craft::$app->getView()->registerAssetBundle(RecipeFieldAsset::class); 158 | } catch (InvalidConfigException $invalidConfigException) { 159 | Craft::error($invalidConfigException->getMessage(), __METHOD__); 160 | } 161 | 162 | // Get our id and namespace 163 | $id = Html::id($this->handle); 164 | $nameSpacedId = Craft::$app->getView()->namespaceInputId($id); 165 | 166 | // Variables to pass down to our field JavaScript to let it namespace properly 167 | $jsonVars = [ 168 | 'id' => $id, 169 | 'name' => $this->handle, 170 | 'namespace' => $nameSpacedId, 171 | 'prefix' => Craft::$app->getView()->namespaceInputId(''), 172 | ]; 173 | $jsonVars = Json::encode($jsonVars); 174 | Craft::$app->getView()->registerJs(sprintf('$(\'#%s-field\').RecipeRecipe(', $nameSpacedId) . $jsonVars . ");"); 175 | 176 | // Set asset elements 177 | $elements = []; 178 | if ($value->imageId) { 179 | if (is_array($value->imageId)) { 180 | $value->imageId = $value->imageId[0]; 181 | } 182 | 183 | $elements = [Craft::$app->getAssets()->getAssetById($value->imageId)]; 184 | } 185 | 186 | $videoElements = []; 187 | if ($value->videoId) { 188 | if (is_array($value->videoId)) { 189 | $value->videoId = $value->videoId[0]; 190 | } 191 | 192 | $videoElements = [Craft::$app->getAssets()->getAssetById($value->videoId)]; 193 | } 194 | 195 | /** @var Settings $settings */ 196 | $settings = RecipePlugin::$plugin->getSettings(); 197 | // Render the input template 198 | try { 199 | return Craft::$app->getView()->renderTemplate( 200 | 'recipe/_components/fields/Recipe_input', 201 | [ 202 | 'name' => $this->handle, 203 | 'value' => $value, 204 | 'field' => $this, 205 | 'id' => $id, 206 | 'nameSpacedId' => $nameSpacedId, 207 | 'prefix' => Craft::$app->getView()->namespaceInputId(''), 208 | 'assetsSourceExists' => count(Craft::$app->getAssets()->findFolders()), 209 | 'elements' => $elements, 210 | 'videoElements' => $videoElements, 211 | 'elementType' => Asset::class, 212 | 'assetSources' => $this->assetSources, 213 | 'hasApiCredentials' => $settings->hasApiCredentials(), 214 | ] 215 | ); 216 | } catch (Throwable $throwable) { 217 | Craft::error($throwable->getMessage(), __METHOD__); 218 | return ''; 219 | } 220 | } 221 | 222 | /** 223 | * Get the asset sources 224 | */ 225 | public function getSourceOptions(): array 226 | { 227 | $sourceOptions = []; 228 | 229 | foreach (Asset::sources('settings') as $volume) { 230 | if (!isset($volume['heading'])) { 231 | $sourceOptions[] = [ 232 | 'label' => Html::encode($volume['label']), 233 | 'value' => $volume['key'], 234 | ]; 235 | } 236 | } 237 | 238 | return $sourceOptions; 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/templates/_integrations/feed-me.twig: -------------------------------------------------------------------------------- 1 | {# ------------------------ #} 2 | {# Available Variables #} 3 | {# ------------------------ #} 4 | {# Attributes: #} 5 | {# type, name, handle, instructions, attribute, default, feed, feedData #} 6 | {# ------------------------ #} 7 | {# Fields: #} 8 | {# name, handle, instructions, feed, feedData, field, fieldClass #} 9 | {# ------------------------ #} 10 | 11 | {% import 'feed-me/_macros' as feedMeMacro %} 12 | {% import '_includes/forms' as forms %} 13 | 14 | {# Special case when inside another complex field (Matrix) #} 15 | {% if parentPath is defined %} 16 | {% set prefixPath = parentPath %} 17 | {% else %} 18 | {% set prefixPath = [handle] %} 19 | {% endif %} 20 | 21 | {% set classes = ['complex-field'] %} 22 | 23 | 24 | 25 |
26 |
27 | 28 |
29 | 30 |
31 | {% namespace 'fieldMapping[' ~ prefixPath | join('][') ~ ']' %} 32 | 33 | {% endnamespace %} 34 |
35 |
36 | 37 | 38 | 39 | {% set subfields = [ 40 | { label: 'Recipe Name', handle: 'name' }, 41 | { label: 'Recipe Author', handle: 'author' }, 42 | { label: 'Recipe Description', handle: 'description' }, 43 | { label: 'Recipe Keywords', handle: 'keywords' }, 44 | { label: 'Recipe Category', handle: 'recipeCategory' }, 45 | { label: 'Recipe Cuisine', handle: 'recipeCuisine' }, 46 | { label: 'Recipe Skill', handle: 'skill', default: { 47 | type: 'select', 48 | options: [ 49 | { label: 'Don\'t import', value: '' }, 50 | { label: 'Beginner', value: 'beginner' }, 51 | { label: 'Intermediate', value: 'intermediate' }, 52 | { label: 'Advanced', value: 'advanced' }, 53 | ], 54 | } }, 55 | { label: 'Recipe Serves', handle: 'serves' }, 56 | { label: 'Recipe Serves Unit', handle: 'servesUnit' }, 57 | { label: 'Recipe Image', handle: 'imageId', default: { 58 | type: 'elementselect', 59 | options: { 60 | limit: 1, 61 | elementType: 'craft\\elements\\Asset', 62 | selectionLabel: "Default Asset" | t('feed-me'), 63 | }, 64 | } }, 65 | { label: 'Recipe Video', handle: 'videoId', default: { 66 | type: 'elementselect', 67 | options: { 68 | limit: 1, 69 | elementType: 'craft\\elements\\Asset', 70 | selectionLabel: "Default Video Asset" | t('feed-me'), 71 | }, 72 | } }, 73 | { label: 'Recipe Prep Time', handle: 'prepTime' }, 74 | { label: 'Recipe Cook Time', handle: 'cookTime' }, 75 | { label: 'Recipe Total Time', handle: 'totalTime' }, 76 | { label: 'Recipe Serving Size', handle: 'servingSize' }, 77 | { label: 'Recipe Calories', handle: 'calories' }, 78 | { label: 'Recipe Carbohydrate Content', handle: 'carbohydrateContent' }, 79 | { label: 'Recipe Cholesterol Content', handle: 'cholesterolContent' }, 80 | { label: 'Recipe Fat Content', handle: 'fatContent' }, 81 | { label: 'Recipe Fiber Content', handle: 'fiberContent' }, 82 | { label: 'Recipe Protein Content', handle: 'proteinContent' }, 83 | { label: 'Recipe Saturated Fat Content', handle: 'saturatedFatContent' }, 84 | { label: 'Recipe Sodium Content', handle: 'sodiumContent' }, 85 | { label: 'Recipe Sugar Content', handle: 'sugarContent' }, 86 | { label: 'Recipe Trans Fat Content', handle: 'transFatContent' }, 87 | { label: 'Recipe Unsaturated Fat Content', handle: 'unsaturatedFatContent' }, 88 | { label: 'Recipe Ingredients', handle: 'ingredients', type: 'table', default: false, cols: { 89 | quantity: { 90 | heading: "Quantity" |t, 91 | }, 92 | units: { 93 | heading: "Units" |t, 94 | default: { 95 | type: "select" |t, 96 | options: { 97 | "": "", 98 | "tsp": "teaspoons" |t, 99 | "tbsp": "tablespoons" |t, 100 | "floz": "fluid ounces" |t, 101 | "cups": "cups" |t, 102 | "oz": "ounces" |t, 103 | "lb": "pounds" |t, 104 | "ml": "milliliters" |t, 105 | "l": "liters" |t, 106 | "mg": "milligram" |t, 107 | "g": "grams" |t, 108 | "kg": "kilograms" |t, 109 | }, 110 | }, 111 | }, 112 | ingredient: { 113 | heading: "Ingredient" |t, 114 | }, 115 | }, }, 116 | { label: 'Recipe Directions', handle: 'directions', type: 'table', default: false, cols: { 117 | direction: { 118 | heading: "Direction" |t, 119 | }, 120 | }, }, 121 | { label: 'Recipe Equipment', handle: 'equipment', type: 'table', default: false, cols: { 122 | equipment: { 123 | heading: "Equipment" |t, 124 | }, 125 | }, }, 126 | { label: 'Recipe Ratings', handle: 'ratings', type: 'table', default: false, cols: { 127 | rating: { 128 | heading: "Rating" |t, 129 | default: { 130 | type: "select" |t, 131 | options: { 132 | "5": "5 Stars" |t, 133 | "4": "4 Stars" |t, 134 | "3": "3 Stars" |t, 135 | "2": "2 Stars" |t, 136 | "1": "1 Star" |t, 137 | } 138 | }, 139 | }, 140 | review: { 141 | heading: "Review" |t, 142 | }, 143 | author: { 144 | heading: "Author" |t, 145 | }, 146 | }, }, 147 | ] %} 148 | 149 | {% for subfield in subfields %} 150 | {% set nameLabel = subfield.label %} 151 | {% set instructionsHandle = handle ~ '[' ~ subfield.handle ~ ']' %} 152 | 153 | {% set path = prefixPath | merge ([ 'fields', subfield.handle ]) %} 154 | 155 | {% set default = subfield.default ?? { 156 | type: 'text', 157 | } %} 158 | 159 | {% set type = subfield.type ?? '_base' %} 160 | 161 | {% if type == 'table' %} 162 | {% set columns = subfield.cols ?? {} %} 163 | 164 | 165 | 166 |
167 |
168 | 169 |
170 |
171 | 172 | 173 | 174 | {% for colHandle, col in columns %} 175 | {% set path = prefixPath | merge ([ 'fields', subfield.handle, 'fields', colHandle ]) %} 176 | 177 | {% set nameLabel = subfield.label ~ ': ' ~ col.heading %} 178 | {% set instructionsHandle = handle ~ '[' ~ subfield.handle ~ ']' ~ '[' ~ colHandle ~ ']' %} 179 | 180 | {% set default = col.default ?? { 181 | type: 'text', 182 | } %} 183 | 184 | {% embed 'feed-me/_includes/fields/_base' %} 185 | {% block additionalFieldSettings %} 186 | 187 | {% endblock %} 188 | 189 | {% block fieldSettings %} 190 | 191 | {% endblock %} 192 | {% endembed %} 193 | {% endfor %} 194 | {% else %} 195 | {% embed 'feed-me/_includes/fields/_base' %} 196 | {% block additionalFieldSettings %} 197 | 198 | {% endblock %} 199 | 200 | {% block fieldSettings %} 201 | 202 | {% endblock %} 203 | {% endembed %} 204 | {% endif %} 205 | {% endfor %} 206 | -------------------------------------------------------------------------------- /src/templates/recipe-nutrition-facts.twig: -------------------------------------------------------------------------------- 1 | 110 | 111 | 112 | {% macro percentage(numerator, denomoninator, serves = 1) %} 113 | {% if serves is empty %} 114 | {% set serves = 1 %} 115 | {% endif %} 116 | {% set result = ((numerator / serves) * 100) / denomoninator %} 117 | {{ result | number_format(0) ~ '%' }} 118 | {% endmacro %} 119 | {% from _self import percentage %} 120 | 121 | {% macro servesValue(value, serves) %} 122 | {% set result = value / serves %} 123 | {{- result | number_format(0) -}} 124 | {% endmacro %} 125 | {% from _self import servesValue %} 126 | 127 |
128 |
129 |
130 |
131 |

Nutrition Facts

132 | {% if value.servingSize | length %} 133 |
Serving 134 | {% set servingSizeParts = value.servingSize | split(' ') %} 135 | Size: {{ servesValue(servingSizeParts[0], value.serves) ~ ' ' ~ servingSizeParts[1] ?? '' }} 136 |
137 | {% endif %} 138 | {% if value.serves | length %} 139 |
Serves: {{ value.serves }}
140 | {% endif %} 141 |
142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | {% if value.calories | length %} 150 | 151 | 152 | 153 | 154 | {% endif %} 155 | 156 | 157 | 158 | {% if value.fatContent | length %} 159 | 160 | 161 | 162 | 163 | {% endif %} 164 | {% if value.saturatedFatContent | length %} 165 | 166 | 167 | 168 | 170 | 171 | {% endif %} 172 | {% if value.transFatContent | length %} 173 | 174 | 175 | 176 | 178 | 179 | {% endif %} 180 | {% if value.cholesterolContent | length %} 181 | 182 | 184 | 185 | 186 | {% endif %} 187 | {% if value.sodiumContent | length %} 188 | 189 | 190 | 191 | 192 | {% endif %} 193 | {% if value.carbohydrateContent | length %} 194 | 195 | 197 | 199 | 200 | {% endif %} 201 | {% if value.fiberContent | length %} 202 | 203 | 204 | 205 | 206 | 207 | {% endif %} 208 | {% if value.sugarContent | length %} 209 | 210 | 211 | 212 | 213 | 214 | {% endif %} 215 | {% if value.proteinContent | length %} 216 | 217 | 218 | 219 | 220 | {% endif %} 221 | 222 | 223 |
Amount Per Serving
Calories:{{ servesValue(value.calories, value.serves) }}
% Daily Value*
Total Fat: {{ servesValue(value.fatContent, value.serves) ~ 'g' }}{{ percentage(value.fatContent, rda.fatContent, value.serves) }}
Saturated Fat: {{ servesValue(value.saturatedFatContent, value.serves) ~ 'g' }} 169 |
Trans Fat: {{ servesValue(value.transFatContent, value.serves) ~ 'g' }} 177 |
183 | Cholesterol: {{ servesValue(value.cholesterolContent, value.serves) ~ 'mg' }}{{ percentage(value.cholesterolContent, rda.cholesterolContent, value.serves) }}
Sodium: {{ servesValue(value.sodiumContent, value.serves) ~ 'mg' }}{{ percentage(value.sodiumContent, rda.sodiumContent, value.serves) }}
Total 196 | Carbohydrate: {{ servesValue(value.carbohydrateContent, value.serves) ~ 'g' }}{{ percentage(value.carbohydrateContent, rda.carbohydrateContent, value.serves) }} 198 |
Dietary Fiber: {{ servesValue(value.fiberContent, value.serves) ~ 'g' }}{{ percentage(value.fiberContent, rda.fiberContent, value.serves) }}
Sugars: {{ servesValue(value.sugarContent, value.serves) ~ 'g' }}{{ percentage(value.sugarContent, rda.sugarContent, value.serves) }}
Protein: {{ servesValue(value.proteinContent, value.serves) ~ 'g' }}{{ percentage(value.proteinContent, rda.proteinContent, value.serves) }}
224 |
225 |
226 |
227 | -------------------------------------------------------------------------------- /src/templates/_components/fields/Recipe_input.twig: -------------------------------------------------------------------------------- 1 | {# 2 | /** 3 | * Recipe plugin for Craft CMS 3.x 4 | * 5 | * Recipe Field Input 6 | * 7 | * @author nystudio107 8 | * @copyright Copyright (c) 2017 nystudio107 9 | * @link https://nystudio107.com 10 | * @package Recipe 11 | * @since 1.0.0 12 | */ 13 | #} 14 | 15 | {% import "_includes/forms" as forms %} 16 | 17 | {% set baseAssetsUrl = view.getAssetManager().getPublishedUrl('@nystudio107/recipe/assetbundles/recipefield/dist', true) %} 18 | 19 |
20 | 37 |
38 |
39 | {{ forms.textField({ 40 | id: id ~ 'name', 41 | class: 'nicetext', 42 | name: name ~ '[name]', 43 | label: 'Recipe Name' |t, 44 | instructions: 'Enter the name of this recipe' |t, 45 | value: value.name, 46 | errors: value.getErrors('name'), 47 | required: true, 48 | }) }} 49 | 50 | {{ forms.textField({ 51 | id: id ~ 'author', 52 | class: 'nicetext', 53 | name: name ~ '[author]', 54 | label: 'Recipe Author' |t, 55 | instructions: 'Enter the person who authored this recipe' |t, 56 | value: value.author, 57 | errors: value.getErrors('author'), 58 | required: false, 59 | }) }} 60 | 61 | {{ forms.textareaField({ 62 | id: id ~ 'description', 63 | class: 'nicetext', 64 | name: name ~ '[description]', 65 | label: 'Recipe Description' |t, 66 | instructions: 'Enter a description of this recipe' |t, 67 | value: value.description, 68 | errors: value.getErrors('description'), 69 | required: false, 70 | }) }} 71 | 72 | {{ forms.textField({ 73 | id: id ~ 'keywords', 74 | class: 'nicetext', 75 | name: name ~ '[keywords]', 76 | label: 'Recipe Keywords' |t, 77 | instructions: 'A comma-separated list of keywords for this recipe' |t, 78 | value: value.keywords, 79 | errors: value.getErrors('keywords'), 80 | required: false, 81 | }) }} 82 | 83 | {{ forms.textField({ 84 | id: id ~ 'recipeCategory', 85 | class: 'nicetext', 86 | name: name ~ '[recipeCategory]', 87 | label: 'Recipe Category' |t, 88 | instructions: 'The category of the recipe—for example, appetizer, entree, etc.' |t, 89 | value: value.recipeCategory, 90 | errors: value.getErrors('recipeCategory'), 91 | required: false, 92 | }) }} 93 | 94 | {{ forms.textField({ 95 | id: id ~ 'recipeCuisine', 96 | class: 'nicetext', 97 | name: name ~ '[recipeCuisine]', 98 | label: 'Recipe Cuisine' |t, 99 | instructions: 'The cuisine of the recipe (for example, French or Ethiopian).' |t, 100 | value: value.recipeCuisine, 101 | errors: value.getErrors('recipeCuisine'), 102 | required: false, 103 | }) }} 104 | 105 | {{ forms.selectField({ 106 | id: id ~ "skill", 107 | name: name ~ "[skill]", 108 | label: 'Recipe Skill' |t, 109 | instructions: 'The skill level required to make this recipe' |t, 110 | options: { 111 | "beginner": "Beginner"|t, 112 | "intermediate": "Intermediate"|t, 113 | "advanced": "Advanced"|t, 114 | }, 115 | value: value.skill, 116 | }) }} 117 | 118 | {{ forms.textField({ 119 | id: id ~ 'serves', 120 | type: 'number', 121 | class: 'nicetext', 122 | size: 3, 123 | name: name ~ '[serves]', 124 | label: 'Recipe Serves' |t, 125 | instructions: 'Enter how many people this recipe serves' |t, 126 | value: value.serves, 127 | errors: value.getErrors('serves'), 128 | required: true, 129 | }) }} 130 | 131 | {{ forms.textField({ 132 | id: id ~ 'servesUnit', 133 | class: 'nicetext', 134 | name: name ~ '[servesUnit]', 135 | label: 'Recipe Serves Unit' |t, 136 | instructions: 'Enter the name of the portions (slices, pints, loafs...)' |t, 137 | value: value.servesUnit, 138 | errors: value.getErrors('servesUnit'), 139 | required: false, 140 | }) }} 141 | 142 | {% if assetsSourceExists %} 143 | {{ forms.elementSelectField({ 144 | elements: elements, 145 | id: id ~ 'imageId', 146 | name: name ~ '[imageId]', 147 | label: 'Recipe Image' |t, 148 | instructions: 'Pick an image that represents this recipe. This is also used for the Recipe Video thumbnail, if a Recipe Video is present.' |t, 149 | elementType: elementType, 150 | criteria: { 151 | 'kind': ['image'], 152 | }, 153 | sourceElementId: value.imageId, 154 | sources: assetSources, 155 | jsClass: 'Craft.AssetSelectInput', 156 | addButtonLabel: "Select an Image" |t, 157 | limit: 1, 158 | }) }} 159 | {% else %} 160 |

No assets sources currently exist.

161 | {% endif %} 162 | 163 | {% if assetsSourceExists %} 164 | {{ forms.elementSelectField({ 165 | elements: videoElements, 166 | id: id ~ 'videoId', 167 | name: name ~ '[videoId]', 168 | label: 'Recipe Video' |t, 169 | instructions: 'Pick an video that represents this recipe' |t, 170 | elementType: elementType, 171 | criteria: { 172 | 'kind': ['video'], 173 | }, 174 | sourceElementId: value.videoId, 175 | sources: assetSources, 176 | jsClass: 'Craft.AssetSelectInput', 177 | addButtonLabel: "Select a Video" |t, 178 | limit: 1, 179 | }) }} 180 | {% else %} 181 |

No assets sources currently exist.

182 | {% endif %} 183 | 184 | {{ forms.editableTableField({ 185 | id: id ~ 'ingredients', 186 | name: name ~ '[ingredients]', 187 | label: 'Recipe Ingredients' |t, 188 | instructions: "Enter the ingredients needed for this recipe by clicking on 'Add an Ingredient'. The quantity should be in decimal form." |t, 189 | required: false, 190 | allowAdd: true, 191 | allowDelete: true, 192 | allowReorder: true, 193 | static: false, 194 | cols: { 195 | quantity: { 196 | heading: "Quantity" |t, 197 | type: "number" |t, 198 | width: '5%', 199 | }, 200 | units: { 201 | heading: "Units" |t, 202 | type: "select" |t, 203 | width: '20%', 204 | options: { 205 | "": "", 206 | "tsp": "teaspoons" |t, 207 | "tbsp": "tablespoons" |t, 208 | "floz": "fluid ounces" |t, 209 | "cups": "cups" |t, 210 | "oz": "ounces" |t, 211 | "lb": "pounds" |t, 212 | "ml": "milliliters" |t, 213 | "l": "liters" |t, 214 | "mg": "milligram" |t, 215 | "g": "grams" |t, 216 | "kg": "kilograms" |t, 217 | } 218 | }, 219 | ingredient: { 220 | heading: "Ingredient" |t, 221 | type: "singleline" |t, 222 | width: '75%', 223 | }, 224 | }, 225 | rows: value.ingredients, 226 | addRowLabel: "Add an Ingredient" |t, 227 | }) }} 228 | 229 | {{ forms.editableTableField({ 230 | id: id ~ 'directions', 231 | name: name ~ '[directions]', 232 | label: 'Recipe Directions' |t, 233 | instructions: "Enter the directions for this recipe by clicking on 'Add a Direction'." |t, 234 | required: false, 235 | allowAdd: true, 236 | allowDelete: true, 237 | allowReorder: true, 238 | static: false, 239 | cols: { 240 | direction: { 241 | heading: "Direction" |t, 242 | type: "multiline" |t, 243 | }, 244 | }, 245 | rows: value.directions, 246 | addRowLabel: "Add a Direction" |t, 247 | }) }} 248 | 249 | {{ forms.editableTableField({ 250 | id: id ~ 'equipment', 251 | name: name ~ '[equipment]', 252 | label: 'Recipe Equipment' |t, 253 | instructions: "Enter the equipment for this recipe by clicking on 'Add Equipment'." |t, 254 | required: false, 255 | allowAdd: true, 256 | allowDelete: true, 257 | allowReorder: true, 258 | static: false, 259 | cols: { 260 | equipment: { 261 | heading: "Equipment" |t, 262 | type: "multiline" |t, 263 | }, 264 | }, 265 | rows: value.equipment, 266 | addRowLabel: "Add Equipment" |t, 267 | }) }} 268 | 269 | {{ forms.textField({ 270 | id: id ~ 'prepTime', 271 | type: 'number', 272 | class: 'nicetext', 273 | name: name ~ '[prepTime]', 274 | size: 3, 275 | label: 'Recipe Prep Time' |t, 276 | instructions: 'The number of minutes it takes to prep this recipe' |t, 277 | value: value.prepTime, 278 | errors: value.getErrors('prepTime'), 279 | required: false, 280 | }) }} 281 | 282 | {{ forms.textField({ 283 | id: id ~ 'cookTime', 284 | type: 'number', 285 | class: 'nicetext', 286 | size: 3, 287 | name: name ~ '[cookTime]', 288 | label: 'Recipe Cook Time' |t, 289 | instructions: 'The number of minutes it takes to cook this recipe' |t, 290 | value: value.cookTime, 291 | errors: value.getErrors('cookTime'), 292 | required: false, 293 | }) }} 294 | 295 | {{ forms.textField({ 296 | id: id ~ 'totalTime', 297 | type: 'number', 298 | class: 'nicetext', 299 | size: 3, 300 | name: name ~ '[totalTime]', 301 | label: 'Recipe Total Time' |t, 302 | instructions: 'The number of minutes it takes for this entire recipe' |t, 303 | value: value.totalTime, 304 | errors: value.getErrors('totalTime'), 305 | required: false, 306 | }) }} 307 |
308 |
309 | 310 | 351 | 352 | 518 |
519 | -------------------------------------------------------------------------------- /src/models/Recipe.php: -------------------------------------------------------------------------------- 1 | 2000, 42 | 'carbohydrateContent' => 275, 43 | 'cholesterolContent' => 300, 44 | 'fatContent' => 78, 45 | 'fiberContent' => 28, 46 | 'proteinContent' => 50, 47 | 'saturatedFatContent' => 20, 48 | 'sodiumContent' => 2300, 49 | 'sugarContent' => 50, 50 | ]; 51 | 52 | // Mapping to convert any of the incorrect plural values 53 | public const NORMALIZE_PLURALS = [ 54 | 'tsps' => 'tsp', 55 | 'tbsps' => 'tbsp', 56 | 'flozs' => 'floz', 57 | 'cups' => 'cups', 58 | 'ozs' => 'oz', 59 | 'lbs' => 'lb', 60 | 'mls' => 'ml', 61 | 'ls' => 'l', 62 | 'mgs' => 'mg', 63 | 'gs' => 'g', 64 | 'kg' => 'kg', 65 | ]; 66 | 67 | // Public Properties 68 | // ========================================================================= 69 | 70 | /** 71 | * @var string 72 | */ 73 | public string $name = ''; 74 | 75 | /** 76 | * @var string 77 | */ 78 | public string $author = ''; 79 | 80 | /** 81 | * @var string 82 | */ 83 | public string $description = ''; 84 | 85 | /** 86 | * @var string 87 | */ 88 | public string $keywords = ''; 89 | 90 | /** 91 | * @var string 92 | */ 93 | public string $recipeCategory = ''; 94 | 95 | /** 96 | * @var string 97 | */ 98 | public string $recipeCuisine = ''; 99 | 100 | /** 101 | * @var string 102 | */ 103 | public string $skill = 'intermediate'; 104 | 105 | /** 106 | * @var int 107 | */ 108 | public int $serves = 1; 109 | 110 | /** 111 | * @var string 112 | */ 113 | public string $servesUnit = ''; 114 | 115 | /** 116 | * @var array 117 | */ 118 | public array $ingredients = []; 119 | 120 | /** 121 | * @var array 122 | */ 123 | public array $directions = []; 124 | 125 | /** 126 | * @var array 127 | */ 128 | public array $equipment = []; 129 | 130 | /** 131 | * @var ?int 132 | */ 133 | public ?int $imageId = null; 134 | 135 | /** 136 | * @var ?int 137 | */ 138 | public ?int $videoId = null; 139 | 140 | /** 141 | * @var int 142 | */ 143 | public int $prepTime = 0; 144 | 145 | /** 146 | * @var int 147 | */ 148 | public int $cookTime = 0; 149 | 150 | /** 151 | * @var int 152 | */ 153 | public int $totalTime = 0; 154 | 155 | /** 156 | * @var ?array 157 | */ 158 | public ?array $ratings = null; 159 | 160 | /** 161 | * @var string 162 | */ 163 | public string $servingSize = ''; 164 | 165 | /** 166 | * @var int 167 | */ 168 | public int $calories = 0; 169 | 170 | /** 171 | * @var int 172 | */ 173 | public int $carbohydrateContent = 0; 174 | 175 | /** 176 | * @var int 177 | */ 178 | public int $cholesterolContent = 0; 179 | 180 | /** 181 | * @var int 182 | */ 183 | public int $fatContent = 0; 184 | 185 | /** 186 | * @var int 187 | */ 188 | public int $fiberContent = 0; 189 | 190 | /** 191 | * @var int 192 | */ 193 | public int $proteinContent = 0; 194 | 195 | /** 196 | * @var int 197 | */ 198 | public int $saturatedFatContent = 0; 199 | 200 | /** 201 | * @var int 202 | */ 203 | public int $sodiumContent = 0; 204 | 205 | /** 206 | * @var int 207 | */ 208 | public int $sugarContent = 0; 209 | 210 | /** 211 | * @var int 212 | */ 213 | public int $transFatContent = 0; 214 | 215 | /** 216 | * @var int 217 | */ 218 | public int $unsaturatedFatContent = 0; 219 | 220 | // Public Methods 221 | // ========================================================================= 222 | 223 | /** 224 | * @inheritdoc 225 | */ 226 | public function init(): void 227 | { 228 | parent::init(); 229 | // Fix any of the incorrect plural values 230 | if (!empty($this->ingredients)) { 231 | foreach ($this->ingredients as &$row) { 232 | if (!empty($row['units']) && !empty(self::NORMALIZE_PLURALS[$row['units']])) { 233 | $row['units'] = self::NORMALIZE_PLURALS[$row['units']]; 234 | } 235 | } 236 | 237 | unset($row); 238 | } 239 | } 240 | 241 | /** 242 | * @inheritdoc 243 | */ 244 | public function rules(): array 245 | { 246 | return [ 247 | ['name', 'string'], 248 | ['author', 'string'], 249 | ['name', 'default', 'value' => ''], 250 | ['description', 'string'], 251 | ['keywords', 'string'], 252 | ['recipeCategory', 'string'], 253 | ['recipeCuisine', 'string'], 254 | ['skill', 'string'], 255 | ['serves', 'integer'], 256 | ['imageId', 'integer'], 257 | ['videoId', 'integer'], 258 | ['prepTime', 'integer'], 259 | ['cookTime', 'integer'], 260 | ['totalTime', 'integer'], 261 | ['servingSize', 'string'], 262 | ['calories', 'integer'], 263 | ['carbohydrateContent', 'integer'], 264 | ['cholesterolContent', 'integer'], 265 | ['fatContent', 'integer'], 266 | ['fiberContent', 'integer'], 267 | ['proteinContent', 'integer'], 268 | ['saturatedFatContent', 'integer'], 269 | ['sodiumContent', 'integer'], 270 | ['sugarContent', 'integer'], 271 | ['transFatContent', 'integer'], 272 | ['unsaturatedFatContent', 'integer'], 273 | [ 274 | [ 275 | 'ingredients', 276 | 'directions', 277 | 'equipment', 278 | ], 279 | ArrayValidator::class, 280 | ], 281 | 282 | ]; 283 | } 284 | 285 | /** 286 | * Return the JSON-LD Structured Data for this recipe 287 | */ 288 | public function getRecipeJSONLD(): array 289 | { 290 | $recipeJSONLD = [ 291 | 'context' => 'https://schema.org', 292 | 'type' => 'Recipe', 293 | 'name' => $this->name, 294 | 'image' => $this->getImageUrl(), 295 | 'description' => $this->description, 296 | 'keywords' => $this->keywords, 297 | 'recipeCategory' => $this->recipeCategory, 298 | 'recipeCuisine' => $this->recipeCuisine, 299 | 'recipeYield' => $this->getServes(), 300 | 'recipeIngredient' => $this->getIngredients('imperial', 0, false), 301 | 'recipeInstructions' => $this->getDirections(false), 302 | 'tool' => $this->getEquipment(false), 303 | ]; 304 | $recipeJSONLD = array_filter($recipeJSONLD); 305 | 306 | if (!empty($this->author)) { 307 | $author = [ 308 | 'type' => 'Person', 309 | 'name' => $this->author, 310 | ]; 311 | $author = array_filter($author); 312 | $recipeJSONLD['author'] = $author; 313 | } 314 | 315 | $videoUrl = $this->getVideoUrl(); 316 | if (!empty($videoUrl)) { 317 | $video = [ 318 | 'type' => 'VideoObject', 319 | 'name' => $this->name, 320 | 'description' => $this->description, 321 | 'contentUrl' => $videoUrl, 322 | 'thumbnailUrl' => $this->getImageUrl(), 323 | 'uploadDate' => $this->getVideoUploadedDate(), 324 | ]; 325 | $video = array_filter($video); 326 | $recipeJSONLD['video'] = $video; 327 | } 328 | 329 | $nutrition = [ 330 | 'type' => 'NutritionInformation', 331 | 'servingSize' => $this->servingSize, 332 | 'calories' => $this->calories, 333 | 'carbohydrateContent' => $this->carbohydrateContent, 334 | 'cholesterolContent' => $this->cholesterolContent, 335 | 'fatContent' => $this->fatContent, 336 | 'fiberContent' => $this->fiberContent, 337 | 'proteinContent' => $this->proteinContent, 338 | 'saturatedFatContent' => $this->saturatedFatContent, 339 | 'sodiumContent' => $this->sodiumContent, 340 | 'sugarContent' => $this->sugarContent, 341 | 'transFatContent' => $this->transFatContent, 342 | 'unsaturatedFatContent' => $this->unsaturatedFatContent, 343 | ]; 344 | $nutrition = array_filter($nutrition); 345 | $recipeJSONLD['nutrition'] = $nutrition; 346 | if (count($recipeJSONLD['nutrition']) === 1) { 347 | unset($recipeJSONLD['nutrition']); 348 | } 349 | 350 | $aggregateRating = $this->getAggregateRating(); 351 | if ($aggregateRating) { 352 | $aggregateRatings = [ 353 | 'type' => 'AggregateRating', 354 | 'ratingCount' => $this->getRatingsCount(), 355 | 'bestRating' => '5', 356 | 'worstRating' => '1', 357 | 'ratingValue' => $aggregateRating, 358 | ]; 359 | $aggregateRatings = array_filter($aggregateRatings); 360 | $recipeJSONLD['aggregateRating'] = $aggregateRatings; 361 | 362 | $reviews = []; 363 | foreach ($this->ratings as $rating) { 364 | $review = [ 365 | 'type' => 'Review', 366 | 'author' => $rating['author'], 367 | 'name' => $this->name . ' ' . Craft::t('recipe', 'Review'), 368 | 'description' => $rating['review'], 369 | 'reviewRating' => [ 370 | 'type' => 'Rating', 371 | 'bestRating' => '5', 372 | 'worstRating' => '1', 373 | 'ratingValue' => $rating['rating'], 374 | ], 375 | ]; 376 | $reviews[] = $review; 377 | } 378 | 379 | $reviews = array_filter($reviews); 380 | $recipeJSONLD['review'] = $reviews; 381 | } 382 | 383 | if ($this->prepTime !== 0) { 384 | $recipeJSONLD['prepTime'] = 'PT' . $this->prepTime . 'M'; 385 | } 386 | 387 | if ($this->cookTime !== 0) { 388 | $recipeJSONLD['cookTime'] = 'PT' . $this->cookTime . 'M'; 389 | } 390 | 391 | if ($this->totalTime !== 0) { 392 | $recipeJSONLD['totalTime'] = 'PT' . $this->totalTime . 'M'; 393 | } 394 | 395 | return $recipeJSONLD; 396 | } 397 | 398 | /** 399 | * Create the SEOmatic MetaJsonLd object for this recipe 400 | * 401 | * @param null $key 402 | */ 403 | public function createRecipeMetaJsonLd($key = null, bool $add = true): ?MetaJsonLd 404 | { 405 | $result = null; 406 | if (Craft::$app->getPlugins()->getPlugin(self::SEOMATIC_PLUGIN_HANDLE)) { 407 | $seomatic = Seomatic::getInstance(); 408 | if ($seomatic !== null) { 409 | $recipeJson = $this->getRecipeJSONLD(); 410 | // If we're adding the MetaJsonLd to the container, and no key is provided, give it a random key 411 | if ($add && $key === null) { 412 | try { 413 | $key = StringHelper::UUID(); 414 | } catch (Exception) { 415 | // That's okay 416 | } 417 | } 418 | 419 | if ($key !== null) { 420 | $recipeJson['key'] = $key; 421 | } 422 | 423 | // If the key is `mainEntityOfPage` add in the URL 424 | if ($key === self::MAIN_ENTITY_KEY) { 425 | $mainEntity = Seomatic::$plugin->jsonLd->get(self::MAIN_ENTITY_KEY); 426 | if ($mainEntity !== null) { 427 | $recipeJson[self::MAIN_ENTITY_KEY] = $mainEntity[self::MAIN_ENTITY_KEY]; 428 | } 429 | } 430 | 431 | $result = Seomatic::$plugin->jsonLd->create( 432 | $recipeJson, 433 | $add 434 | ); 435 | } 436 | } 437 | 438 | return $result; 439 | } 440 | 441 | /** 442 | * Render the JSON-LD Structured Data for this recipe 443 | * 444 | * 445 | */ 446 | public function renderRecipeJSONLD(bool $raw = true): string|Markup 447 | { 448 | return $this->renderJsonLd($this->getRecipeJSONLD(), $raw); 449 | } 450 | 451 | /** 452 | * Get the URL to the recipe's image 453 | * 454 | * @param null $transform 455 | */ 456 | public function getImageUrl($transform = null): ?string 457 | { 458 | $result = ''; 459 | if ($this->imageId) { 460 | $image = Craft::$app->getAssets()->getAssetById($this->imageId); 461 | if ($image) { 462 | $result = $image->getUrl($transform); 463 | } 464 | } 465 | 466 | return $result; 467 | } 468 | 469 | /** 470 | * Get the URL to the recipe's video 471 | */ 472 | public function getVideoUrl(): ?string 473 | { 474 | $result = ''; 475 | if ($this->videoId) { 476 | $video = Craft::$app->getAssets()->getAssetById($this->videoId); 477 | if ($video) { 478 | $result = $video->getUrl(); 479 | } 480 | } 481 | 482 | return $result; 483 | } 484 | 485 | /** 486 | * Get the URL to the recipe's uploaded date 487 | */ 488 | public function getVideoUploadedDate(): ?string 489 | { 490 | $result = ''; 491 | if ($this->videoId) { 492 | $video = Craft::$app->getAssets()->getAssetById($this->videoId); 493 | if ($video) { 494 | $result = $video->dateCreated->format('c'); 495 | } 496 | } 497 | 498 | return $result; 499 | } 500 | 501 | /** 502 | * Render the Nutrition Facts template 503 | */ 504 | public function renderNutritionFacts(array $rda = self::US_RDA): Markup 505 | { 506 | return PluginTemplate::renderPluginTemplate( 507 | 'recipe-nutrition-facts', 508 | [ 509 | 'value' => $this, 510 | 'rda' => $rda, 511 | ] 512 | ); 513 | } 514 | 515 | /** 516 | * Get all the ingredients for this recipe 517 | */ 518 | public function getIngredients(string $outputUnits = 'imperial', int $serving = 0, bool $raw = true): array 519 | { 520 | $result = []; 521 | 522 | foreach ($this->ingredients as $row) { 523 | $convertedUnits = ''; 524 | $ingredient = ''; 525 | if ($row['quantity']) { 526 | // Multiply the quantity by how many servings we want 527 | $multiplier = 1; 528 | if ($serving > 0) { 529 | $multiplier = $serving / $this->serves; 530 | } 531 | 532 | $quantity = $row['quantity'] * $multiplier; 533 | $originalQuantity = $quantity; 534 | 535 | // Do the imperial->metric units conversion 536 | if ($outputUnits === 'imperial') { 537 | switch ($row['units']) { 538 | case 'ml': 539 | $convertedUnits = 'tsp'; 540 | $quantity *= 0.2; 541 | break; 542 | case 'l': 543 | $convertedUnits = 'cups'; 544 | $quantity *= 4.2; 545 | break; 546 | case 'mg': 547 | $convertedUnits = 'oz'; 548 | $quantity *= 0.000035274; 549 | break; 550 | case 'g': 551 | $convertedUnits = 'oz'; 552 | $quantity *= 0.035274; 553 | break; 554 | case 'kg': 555 | $convertedUnits = 'lb'; 556 | $quantity *= 2.2046226218; 557 | break; 558 | } 559 | } 560 | 561 | // Do the metric->imperial units conversion 562 | if ($outputUnits === 'metric') { 563 | switch ($row['units']) { 564 | case 'tsp': 565 | $convertedUnits = 'ml'; 566 | $quantity *= 4.929; 567 | break; 568 | case 'tbsp': 569 | $convertedUnits = 'ml'; 570 | $quantity *= 14.787; 571 | break; 572 | case 'floz': 573 | $convertedUnits = 'ml'; 574 | $quantity *= 29.574; 575 | break; 576 | case 'cups': 577 | $convertedUnits = 'l'; 578 | $quantity *= 0.236588; 579 | break; 580 | case 'oz': 581 | $convertedUnits = 'g'; 582 | $quantity *= 28.3495; 583 | break; 584 | case 'lb': 585 | $convertedUnits = 'kg'; 586 | $quantity *= 0.45359237; 587 | break; 588 | } 589 | 590 | $quantity = round($quantity, 1); 591 | } 592 | 593 | // Convert units to nice fractions 594 | $quantity = $this->convertToFractions($quantity); 595 | 596 | $ingredient .= $quantity; 597 | 598 | if ($row['units']) { 599 | $units = $row['units']; 600 | if ($convertedUnits !== '' && $convertedUnits !== '0') { 601 | $units = $convertedUnits; 602 | } 603 | 604 | if ($originalQuantity <= 1) { 605 | $units = rtrim($units); 606 | $units = rtrim($units, 's'); 607 | } 608 | 609 | $ingredient .= ' ' . $units; 610 | } 611 | } 612 | 613 | if ($row['ingredient']) { 614 | $ingredient .= ' ' . $row['ingredient']; 615 | } 616 | 617 | if ($raw) { 618 | $ingredient = Template::raw($ingredient); 619 | } 620 | 621 | $result[] = $ingredient; 622 | } 623 | 624 | return $result; 625 | } 626 | 627 | /** 628 | * Get all the directions for this recipe 629 | * 630 | * 631 | * @return array 632 | */ 633 | public function getDirections(bool $raw = true): array 634 | { 635 | $result = []; 636 | foreach ($this->directions as $row) { 637 | $direction = $row['direction']; 638 | if ($raw) { 639 | $direction = Template::raw($direction); 640 | } 641 | 642 | $result[] = $direction; 643 | } 644 | 645 | return $result; 646 | } 647 | 648 | /** 649 | * Get all the equipment for this recipe 650 | */ 651 | public function getEquipment(bool $raw = true): array 652 | { 653 | $result = []; 654 | foreach ($this->equipment as $row) { 655 | $equipment = $row['equipment']; 656 | if ($raw) { 657 | $equipment = Template::raw($equipment); 658 | } 659 | 660 | $result[] = $equipment; 661 | } 662 | 663 | return $result; 664 | } 665 | 666 | /** 667 | * Get the aggregate rating from all the ratings 668 | */ 669 | public function getAggregateRating(): float|int|string 670 | { 671 | $result = 0; 672 | $total = 0; 673 | if (!empty($this->ratings)) { 674 | foreach ($this->ratings as $row) { 675 | $result += $row['rating']; 676 | ++$total; 677 | } 678 | 679 | $result /= $total; 680 | } else { 681 | $result = ''; 682 | } 683 | 684 | return $result; 685 | } 686 | 687 | /** 688 | * Get the total number of ratings 689 | */ 690 | public function getRatingsCount(): int 691 | { 692 | return count($this->ratings); 693 | } 694 | 695 | /** 696 | * Returns concatenated serves with its unit 697 | */ 698 | public function getServes(): int|string 699 | { 700 | if (!empty($this->servesUnit)) { 701 | return $this->serves . ' ' . $this->servesUnit; 702 | } 703 | 704 | return $this->serves; 705 | } 706 | 707 | /** 708 | * Convert decimal numbers into fractions 709 | * 710 | * @param $quantity 711 | */ 712 | private function convertToFractions($quantity): string 713 | { 714 | $whole = floor($quantity); 715 | // Round the mantissa so we can do a floating point comparison without 716 | // weirdness, per: https://www.php.net/manual/en/language.types.float.php#113703 717 | $fraction = round($quantity - $whole, 3); 718 | switch ($fraction) { 719 | case 0: 720 | $fraction = ''; 721 | break; 722 | case 0.25: 723 | $fraction = ' ¼'; 724 | break; 725 | case 0.33: 726 | $fraction = ' ⅓'; 727 | break; 728 | case 0.66: 729 | $fraction = ' ⅔'; 730 | break; 731 | case 0.165: 732 | $fraction = ' ⅙'; 733 | break; 734 | case 0.5: 735 | $fraction = ' ½'; 736 | break; 737 | case 0.75: 738 | $fraction = ' ¾'; 739 | break; 740 | case 0.125: 741 | $fraction = ' ⅛'; 742 | break; 743 | case 0.375: 744 | $fraction = ' ⅜'; 745 | break; 746 | case 0.625: 747 | $fraction = ' ⅝'; 748 | break; 749 | case 0.875: 750 | $fraction = ' ⅞'; 751 | break; 752 | default: 753 | $precision = 1; 754 | $pnum = round($fraction, $precision); 755 | $denominator = 10 ** $precision; 756 | $numerator = $pnum * $denominator; 757 | $fraction = ' ' 758 | . $numerator 759 | . '' 760 | . $denominator 761 | . ''; 762 | break; 763 | } 764 | 765 | if ($whole == 0) { 766 | $whole = ''; 767 | } 768 | 769 | return $whole . $fraction; 770 | } 771 | 772 | // Private Methods 773 | // ========================================================================= 774 | 775 | /** 776 | * Renders a JSON-LD representation of the schema 777 | * 778 | * @param $json 779 | */ 780 | private function renderJsonLd($json, bool $raw = true): string|Markup 781 | { 782 | $linebreak = ''; 783 | 784 | // If `devMode` is enabled, make the JSON-LD human-readable 785 | if (Craft::$app->getConfig()->getGeneral()->devMode) { 786 | $linebreak = PHP_EOL; 787 | } 788 | 789 | // Render the resulting JSON-LD 790 | $result = ''; 795 | 796 | if ($raw) { 797 | $result = Template::raw($result); 798 | } 799 | 800 | return $result; 801 | } 802 | } 803 | --------------------------------------------------------------------------------