├── 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 | [](https://scrutinizer-ci.com/g/nystudio107/craft-recipe/?branch=v5) [](https://scrutinizer-ci.com/g/nystudio107/craft-recipe/?branch=v5) [](https://scrutinizer-ci.com/g/nystudio107/craft-recipe/build-status/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 | 
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 | Get Started
29 |
30 |
31 |
32 |
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 |
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 |
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 |
142 |
143 |
144 |
145 | Amount Per Serving
146 |
147 |
148 |
149 | {% if value.calories | length %}
150 |
151 | Calories:
152 | {{ servesValue(value.calories, value.serves) }}
153 |
154 | {% endif %}
155 |
156 | % Daily Value*
157 |
158 | {% if value.fatContent | length %}
159 |
160 | Total Fat: {{ servesValue(value.fatContent, value.serves) ~ 'g' }}
161 | {{ percentage(value.fatContent, rda.fatContent, value.serves) }}
162 |
163 | {% endif %}
164 | {% if value.saturatedFatContent | length %}
165 |
166 |
167 | Saturated Fat: {{ servesValue(value.saturatedFatContent, value.serves) ~ 'g' }}
168 |
169 |
170 |
171 | {% endif %}
172 | {% if value.transFatContent | length %}
173 |
174 |
175 | Trans Fat: {{ servesValue(value.transFatContent, value.serves) ~ 'g' }}
176 |
177 |
178 |
179 | {% endif %}
180 | {% if value.cholesterolContent | length %}
181 |
182 |
183 | Cholesterol: {{ servesValue(value.cholesterolContent, value.serves) ~ 'mg' }}
184 | {{ percentage(value.cholesterolContent, rda.cholesterolContent, value.serves) }}
185 |
186 | {% endif %}
187 | {% if value.sodiumContent | length %}
188 |
189 | Sodium: {{ servesValue(value.sodiumContent, value.serves) ~ 'mg' }}
190 | {{ percentage(value.sodiumContent, rda.sodiumContent, value.serves) }}
191 |
192 | {% endif %}
193 | {% if value.carbohydrateContent | length %}
194 |
195 | Total
196 | Carbohydrate: {{ servesValue(value.carbohydrateContent, value.serves) ~ 'g' }}
197 | {{ percentage(value.carbohydrateContent, rda.carbohydrateContent, value.serves) }}
198 |
199 |
200 | {% endif %}
201 | {% if value.fiberContent | length %}
202 |
203 |
204 | Dietary Fiber: {{ servesValue(value.fiberContent, value.serves) ~ 'g' }}
205 | {{ percentage(value.fiberContent, rda.fiberContent, value.serves) }}
206 |
207 | {% endif %}
208 | {% if value.sugarContent | length %}
209 |
210 |
211 | Sugars: {{ servesValue(value.sugarContent, value.serves) ~ 'g' }}
212 | {{ percentage(value.sugarContent, rda.sugarContent, value.serves) }}
213 |
214 | {% endif %}
215 | {% if value.proteinContent | length %}
216 |
217 | Protein: {{ servesValue(value.proteinContent, value.serves) ~ 'g' }}
218 | {{ percentage(value.proteinContent, rda.proteinContent, value.serves) }}
219 |
220 | {% endif %}
221 |
222 |
223 |
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 |
21 |
36 |
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 |
311 |
312 | {{ forms.editableTableField({
313 | id: id ~ 'ratings',
314 | name: name ~ '[ratings]',
315 | label: 'Recipe Ratings' |t,
316 | instructions: "Enter ratings of this recipe by clicking on 'Add a Rating'." |t,
317 | required: false,
318 | allowAdd: true,
319 | allowDelete: true,
320 | allowReorder: true,
321 | static: false,
322 | cols: {
323 | rating: {
324 | heading: "Rating" |t,
325 | type: "select" |t,
326 | width: '10%',
327 | options: {
328 | "5": "5 Stars" |t,
329 | "4": "4 Stars" |t,
330 | "3": "3 Stars" |t,
331 | "2": "2 Stars" |t,
332 | "1": "1 Star" |t,
333 | }
334 | },
335 | review: {
336 | heading: "Review" |t,
337 | type: "singleline" |t,
338 | width: '60%',
339 | },
340 | author: {
341 | heading: "Author" |t,
342 | type: "singleline" |t,
343 | width: '30%',
344 | },
345 | },
346 | rows: value.ratings,
347 | addRowLabel: "Add a Rating" |t,
348 | }) }}
349 |
350 |
351 |
352 |
353 |
354 | {% if hasApiCredentials %}
355 |
356 |
357 |
358 | Fetch Nutritional Information
359 |
360 |
361 | {% endif %}
362 |
363 | {{ forms.textField({
364 | id: id ~ 'servingSize',
365 | class: 'nicetext',
366 | name: name ~ '[servingSize]',
367 | label: 'Recipe Serving Size' |t,
368 | instructions: 'The serving size, in terms of the number of volume or mass' |t,
369 | value: value.servingSize,
370 | errors: value.getErrors('servingSize'),
371 | required: false,
372 | }) }}
373 |
374 | {{ forms.textField({
375 | id: id ~ 'calories',
376 | type: 'number',
377 | class: 'nicetext',
378 | size: 3,
379 | name: name ~ '[calories]',
380 | label: 'Recipe Calories' |t,
381 | instructions: 'The number of calories per serving' |t,
382 | value: value.calories,
383 | errors: value.getErrors('calories'),
384 | required: false,
385 | }) }}
386 |
387 | {{ forms.textField({
388 | id: id ~ 'carbohydrateContent',
389 | type: 'number',
390 | class: 'nicetext',
391 | size: 3,
392 | name: name ~ '[carbohydrateContent]',
393 | label: 'Recipe Carbohydrate Content' |t,
394 | instructions: 'The number of grams of carbohydrates per serving' |t,
395 | value: value.carbohydrateContent,
396 | errors: value.getErrors('carbohydrateContent'),
397 | required: false,
398 | }) }}
399 |
400 | {{ forms.textField({
401 | id: id ~ 'cholesterolContent',
402 | type: 'number',
403 | class: 'nicetext',
404 | size: 3,
405 | name: name ~ '[cholesterolContent]',
406 | label: 'Recipe Cholesterol Content' |t,
407 | instructions: 'The number of milligrams of cholesterol per serving' |t,
408 | value: value.cholesterolContent,
409 | errors: value.getErrors('cholesterolContent'),
410 | required: false,
411 | }) }}
412 |
413 | {{ forms.textField({
414 | id: id ~ 'fatContent',
415 | type: 'number',
416 | class: 'nicetext',
417 | size: 3,
418 | name: name ~ '[fatContent]',
419 | label: 'Recipe Fat Content' |t,
420 | instructions: 'The number of grams of fat per serving' |t,
421 | value: value.fatContent,
422 | errors: value.getErrors('fatContent'),
423 | required: false,
424 | }) }}
425 |
426 | {{ forms.textField({
427 | id: id ~ 'fiberContent',
428 | type: 'number',
429 | class: 'nicetext',
430 | size: 3,
431 | name: name ~ '[fiberContent]',
432 | label: 'Recipe Fiber Content' |t,
433 | instructions: 'The number of grams of fiber per serving' |t,
434 | value: value.fiberContent,
435 | errors: value.getErrors('fiberContent'),
436 | required: false,
437 | }) }}
438 |
439 | {{ forms.textField({
440 | id: id ~ 'proteinContent',
441 | type: 'number',
442 | class: 'nicetext',
443 | size: 3,
444 | name: name ~ '[proteinContent]',
445 | label: 'Recipe Protein Content' |t,
446 | instructions: 'The number of grams of protein per serving' |t,
447 | value: value.proteinContent,
448 | errors: value.getErrors('proteinContent'),
449 | required: false,
450 | }) }}
451 |
452 | {{ forms.textField({
453 | id: id ~ 'saturatedFatContent',
454 | type: 'number',
455 | class: 'nicetext',
456 | size: 3,
457 | name: name ~ '[saturatedFatContent]',
458 | label: 'Recipe Saturated Fat Content' |t,
459 | instructions: 'The number of grams of saturated fat per serving' |t,
460 | value: value.saturatedFatContent,
461 | errors: value.getErrors('saturatedFatContent'),
462 | required: false,
463 | }) }}
464 |
465 | {{ forms.textField({
466 | id: id ~ 'sodiumContent',
467 | type: 'number',
468 | class: 'nicetext',
469 | size: 3,
470 | name: name ~ '[sodiumContent]',
471 | label: 'Recipe Sodium Content' |t,
472 | instructions: 'The number of milligrams of sodium per serving' |t,
473 | value: value.sodiumContent,
474 | errors: value.getErrors('sodiumContent'),
475 | required: false,
476 | }) }}
477 |
478 | {{ forms.textField({
479 | id: id ~ 'sugarContent',
480 | type: 'number',
481 | class: 'nicetext',
482 | size: 3,
483 | name: name ~ '[sugarContent]',
484 | label: 'Recipe Sugar Content' |t,
485 | instructions: 'The number of grams of sugar per serving' |t,
486 | value: value.sugarContent,
487 | errors: value.getErrors('sugarContent'),
488 | required: false,
489 | }) }}
490 |
491 | {{ forms.textField({
492 | id: id ~ 'transFatContent',
493 | type: 'number',
494 | class: 'nicetext',
495 | size: 3,
496 | name: name ~ '[transFatContent]',
497 | label: 'Recipe Trans Fat Content' |t,
498 | instructions: 'The number of grams of trans fat per serving' |t,
499 | value: value.transFatContent,
500 | errors: value.getErrors('transFatContent'),
501 | required: false,
502 | }) }}
503 |
504 | {{ forms.textField({
505 | id: id ~ 'unsaturatedFatContent',
506 | type: 'number',
507 | class: 'nicetext',
508 | size: 3,
509 | name: name ~ '[unsaturatedFatContent]',
510 | label: 'Recipe Unsaturated Fat Content' |t,
511 | instructions: 'The number of grams of unsaturated fat per serving' |t,
512 | value: value.unsaturatedFatContent,
513 | errors: value.getErrors('unsaturatedFatContent'),
514 | required: false,
515 | }) }}
516 |
517 |
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 |
--------------------------------------------------------------------------------