├── src ├── web │ └── assets │ │ ├── src │ │ ├── js │ │ │ ├── @types │ │ │ │ ├── shims.d.ts │ │ │ │ ├── craft.d.ts │ │ │ │ ├── autocomplete.d.ts │ │ │ │ └── code-editor.d.ts │ │ │ ├── default-monaco-editor-options.ts │ │ │ ├── language-icons.ts │ │ │ ├── autocomplete.ts │ │ │ └── code-editor.ts │ │ └── css │ │ │ └── code-editor.pcss │ │ └── dist │ │ ├── css.worker.js.gz │ │ ├── js │ │ ├── vendors.js.gz │ │ ├── code-editor.js.gz │ │ ├── vendors.js.map.gz │ │ ├── code-editor.js.map.gz │ │ ├── runtime.js │ │ ├── runtime.js.map │ │ └── code-editor.js │ │ ├── ts.worker.js.gz │ │ ├── css │ │ ├── vendors.css.gz │ │ └── styles.css │ │ ├── fonts │ │ └── codicon.ttf │ │ ├── html.worker.js.gz │ │ ├── json.worker.js.gz │ │ ├── css.worker.js.map.gz │ │ ├── editor.worker.js.gz │ │ ├── html.worker.js.map.gz │ │ ├── json.worker.js.map.gz │ │ ├── ts.worker.js.map.gz │ │ ├── editor.worker.js.map.gz │ │ └── manifest.json ├── events │ ├── RegisterTwigfieldAutocompletesEvent.php │ └── RegisterTwigValidatorVariablesEvent.php ├── types │ ├── AutocompleteTypes.php │ ├── CompletionItemInsertTextRule.php │ └── CompleteItemKind.php ├── translations │ └── en │ │ └── twigfield.php ├── base │ ├── ObjectParserInterface.php │ ├── AutocompleteInterface.php │ ├── Autocomplete.php │ └── ObjectParserAutocomplete.php ├── config.php ├── assetbundles │ └── twigfield │ │ └── TwigfieldAsset.php ├── controllers │ └── AutocompleteController.php ├── autocompletes │ ├── ObjectAutocomplete.php │ ├── EnvironmentVariableAutocomplete.php │ ├── CraftApiAutocomplete.php │ ├── SectionShorthandFieldsAutocomplete.php │ └── TwigLanguageAutocomplete.php ├── models │ ├── Settings.php │ └── CompleteItem.php ├── helpers │ └── Config.php ├── validators │ ├── TwigTemplateValidator.php │ └── TwigObjectTemplateValidator.php ├── templates │ └── twigfield.twig ├── Twigfield.php └── services │ └── AutocompleteService.php ├── LICENSE.md ├── tsconfig.json ├── composer.json ├── CHANGELOG.md └── README.md /src/web/assets/src/js/@types/shims.d.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/web/assets/dist/css.worker.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/css.worker.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/js/vendors.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/js/vendors.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/ts.worker.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/ts.worker.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/css/vendors.css.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/css/vendors.css.gz -------------------------------------------------------------------------------- /src/web/assets/dist/fonts/codicon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/fonts/codicon.ttf -------------------------------------------------------------------------------- /src/web/assets/dist/html.worker.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/html.worker.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/json.worker.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/json.worker.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/css.worker.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/css.worker.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/editor.worker.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/editor.worker.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/html.worker.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/html.worker.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/js/code-editor.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/js/code-editor.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/js/vendors.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/js/vendors.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/json.worker.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/json.worker.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/ts.worker.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/ts.worker.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/editor.worker.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/editor.worker.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/js/code-editor.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-twigfield/develop/src/web/assets/dist/js/code-editor.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/src/js/@types/craft.d.ts: -------------------------------------------------------------------------------- 1 | type CraftTFunction = (category: string, message: string) => string; 2 | 3 | interface Craft { 4 | t: CraftTFunction, 5 | 6 | [key: string]: any; 7 | } 8 | -------------------------------------------------------------------------------- /src/web/assets/src/js/@types/autocomplete.d.ts: -------------------------------------------------------------------------------- 1 | enum AutocompleteTypes { 2 | TwigExpressionAutocomplete = "TwigExpressionAutocomplete", 3 | GeneralAutocomplete = "GeneralAutocomplete", 4 | } 5 | 6 | interface AutocompleteItem { 7 | __completions: monaco.languages.CompletionItem, 8 | 9 | [key: string]: AutocompleteItem; 10 | } 11 | 12 | interface Autocomplete { 13 | name: string, 14 | type: AutocompleteTypes, 15 | hasSubProperties: boolean, 16 | 17 | [key: string]: AutocompleteItem; 18 | } 19 | 20 | type AutocompleteResponse = {[key: string]: Autocomplete}; 21 | -------------------------------------------------------------------------------- /src/web/assets/dist/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "code-editor.js": "js/code-editor.js", 3 | "runtime.js": "js/runtime.js", 4 | "vendors.css": "css/vendors.css", 5 | "vendors.js": "js/vendors.js", 6 | "styles.css": "css/styles.css", 7 | "ts.worker.js": "ts.worker.js", 8 | "css.worker.js": "css.worker.js", 9 | "html.worker.js": "html.worker.js", 10 | "json.worker.js": "json.worker.js", 11 | "editor.worker.js": "editor.worker.js", 12 | "fonts/codicon.ttf": "fonts/codicon.ttf", 13 | "code-editor.js.map": "js/code-editor.js.map", 14 | "runtime.js.map": "js/runtime.js.map", 15 | "vendors.js.map": "js/vendors.js.map" 16 | } -------------------------------------------------------------------------------- /src/web/assets/src/js/@types/code-editor.d.ts: -------------------------------------------------------------------------------- 1 | type MakeMonacoEditorFn = (elementId: string, fieldType: string, wrapperClass: string, editorOptions: string, twigfieldOptions: string, endpointUrl: string, placeholderText: string) => monaco.editor.IStandaloneCodeEditor | undefined; 2 | 3 | type SetMonacoEditorLanguageFn = (editor: monaco.editor.IStandaloneCodeEditor, language: string | undefined, elementId: string) => void; 4 | 5 | type SetMonacoEditorThemeFn = (editor: monaco.editor.IStandaloneCodeEditor, theme: string | undefined) => void; 6 | 7 | interface CodeEditorOptions { 8 | singleLineEditor?: boolean, 9 | 10 | [key: string]: any; 11 | } 12 | -------------------------------------------------------------------------------- /src/events/RegisterTwigfieldAutocompletesEvent.php: -------------------------------------------------------------------------------- 1 | {error}' => 'Error rendering template string -> {error}', 18 | 'Is not a string.' => 'Is not a string.', 19 | 'Twig Function' => 'Twig Function', 20 | 'Twig Filter' => 'Twig Filter', 21 | 'Twig Tag' => 'Twig Tag', 22 | 'Custom Field Shorthand' => 'Custom Field Shorthand', 23 | 'Field Shorthand' => 'Field Shorthand', 24 | 'Twig code is supported.' => 'Twig code is supported.', 25 | ]; 26 | -------------------------------------------------------------------------------- /src/events/RegisterTwigValidatorVariablesEvent.php: -------------------------------------------------------------------------------- 1 | value format that should be available during the template rendering 29 | */ 30 | public $variables = []; 31 | } 32 | -------------------------------------------------------------------------------- /src/types/CompletionItemInsertTextRule.php: -------------------------------------------------------------------------------- 1 | false, 28 | // The default autcompletes to use for the default `Twigfield` field type 29 | 'defaultTwigfieldAutocompletes' => [ 30 | CraftApiAutocomplete::class, 31 | TwigLanguageAutocomplete::class, 32 | SectionShorthandFieldsAutocomplete::class, 33 | ] 34 | ]; 35 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nystudio107/craft-twigfield", 3 | "description": "Provides a twig editor field with Twig & Craft API autocomplete", 4 | "type": "yii2-extension", 5 | "version": "1.0.20", 6 | "keywords": [ 7 | "craft", 8 | "cms", 9 | "craftcms", 10 | "twig", 11 | "editor", 12 | "autocomplete", 13 | "auto", 14 | "complete" 15 | ], 16 | "support": { 17 | "docs": "https://github.com/nystudio107/craft-twigfield/blob/v1/README.md", 18 | "issues": "https://github.com/nystudio107/craft-twigfield/issues", 19 | "source": "https://github.com/nystudio107/craft-twigfield" 20 | }, 21 | "license": "MIT", 22 | "authors": [ 23 | { 24 | "name": "nystudio107", 25 | "homepage": "https://nystudio107.com" 26 | } 27 | ], 28 | "require": { 29 | "phpdocumentor/reflection-docblock": "^5.0.0" 30 | }, 31 | "require-dev": { 32 | "craftcms/cms": "^3.0.0 || ^4.0.0" 33 | }, 34 | "config": { 35 | "allow-plugins": { 36 | "craftcms/plugin-installer": true, 37 | "yiisoft/yii2-composer": true 38 | }, 39 | "optimize-autoloader": true, 40 | "sort-packages": true 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "nystudio107\\twigfield\\": "src/" 45 | } 46 | }, 47 | "extra": { 48 | "bootstrap": "nystudio107\\twigfield\\Twigfield" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/base/AutocompleteInterface.php: -------------------------------------------------------------------------------- 1 | label 39 | */ 40 | public function addCompleteItem(CompleteItem $item, string $path = ''): void; 41 | 42 | /** 43 | * Get the array of complete items 44 | * 45 | * @return array 46 | */ 47 | public function getCompleteItems(): array; 48 | } 49 | -------------------------------------------------------------------------------- /src/assetbundles/twigfield/TwigfieldAsset.php: -------------------------------------------------------------------------------- 1 | sourcePath = '@nystudio107/twigfield/web/assets/dist'; 32 | $this->depends = [ 33 | ]; 34 | $this->css = [ 35 | 'css/vendors.css', 36 | 'css/styles.css', 37 | ]; 38 | $this->js = [ 39 | 'js/runtime.js', 40 | 'js/vendors.js', 41 | 'js/code-editor.js' 42 | ]; 43 | 44 | parent::init(); 45 | } 46 | 47 | /** 48 | * @inheritdoc 49 | */ 50 | public function registerAssetFiles($view): void 51 | { 52 | parent::registerAssetFiles($view); 53 | 54 | if ($view instanceof View) { 55 | $view->registerTranslations('twigfield', [ 56 | 'Twig code is supported.' 57 | ]); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/controllers/AutocompleteController.php: -------------------------------------------------------------------------------- 1 | allowFrontendAccess) { 31 | $this->allowAnonymous = 1; 32 | } 33 | 34 | return parent::beforeAction($action); 35 | } 36 | 37 | /** 38 | * Return all of the autocomplete items in JSON format 39 | * 40 | * @param string $fieldType 41 | * @param string $twigfieldOptions 42 | * @return Response 43 | */ 44 | public function actionIndex(string $fieldType = Twigfield::DEFAULT_FIELD_TYPE, string $twigfieldOptions = ''): Response 45 | { 46 | $options = []; 47 | $parsedJson = Json::decodeIfJson($twigfieldOptions); 48 | if (is_array($parsedJson)) { 49 | $options = $parsedJson; 50 | } 51 | $result = Twigfield::getInstance()->autocomplete->generateAutocompletes($fieldType, $options); 52 | 53 | return $this->asJson($result); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/types/CompleteItemKind.php: -------------------------------------------------------------------------------- 1 | object instanceof BaseObject) { 56 | $this->parseObject('', $this->object, 0); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/models/Settings.php: -------------------------------------------------------------------------------- 1 | =i)&&Object.keys(t.O).every((function(e){return t.O[e](r[c])}))?r.splice(c--,1):(f=!1,i0&&e[a-1][2]>i;a--)e[a]=e[a-1];e[a]=[r,o,i]},t.d=function(e,n){for(var r in n)t.o(n,r)&&!t.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:n[r]})},t.e=function(){return Promise.resolve()},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.p="",function(){var e={666:0,532:0};t.O.j=function(n){return 0===e[n]};var n=function(n,r){var o,i,u=r[0],f=r[1],c=r[2],l=0;if(u.some((function(n){return 0!==e[n]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var a=c(t)}for(n&&n(r);lgetEnvSuggestions(true); 54 | foreach ($suggestions as $suggestion) { 55 | foreach ($suggestion['data'] as $item) { 56 | $name = $item['name']; 57 | $prefix = $name[0]; 58 | $trimmedName = ltrim($name, $prefix); 59 | CompleteItem::create() 60 | ->label($name) 61 | ->insertText($trimmedName) 62 | ->filterText($trimmedName) 63 | ->detail($item['hint']) 64 | ->kind(CompleteItemKind::ConstantKind) 65 | ->sortText('~' . $name) 66 | ->add($this); 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/helpers/Config.php: -------------------------------------------------------------------------------- 1 | getConfig()->env; 43 | // Try craft/config first 44 | $path = Craft::getAlias('@config/' . $fileName, false); 45 | if ($path === false || !file_exists($path)) { 46 | // Now try our own internal config 47 | $path = Craft::getAlias('@nystudio107/twigfield/config.php', false); 48 | if ($path === false || !file_exists($path)) { 49 | return []; 50 | } 51 | } 52 | // Make sure we got a config file 53 | if (!is_array($config = @include $path)) { 54 | return []; 55 | } 56 | // If it's not a multi-environment config, return the whole thing 57 | if (!array_key_exists('*', $config)) { 58 | return $config; 59 | } 60 | // If no environment was specified, just look in the '*' array 61 | if ($currentEnv === null) { 62 | return $config['*']; 63 | } 64 | $mergedConfig = []; 65 | foreach ($config as $env => $envConfig) { 66 | if ($env === '*' || StringHelper::contains($currentEnv, $env)) { 67 | $mergedConfig = ArrayHelper::merge($mergedConfig, $envConfig); 68 | } 69 | } 70 | 71 | return $mergedConfig; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/base/Autocomplete.php: -------------------------------------------------------------------------------- 1 | validate()) { 84 | Craft::debug(print_r($item->getErrors(), true), __METHOD__); 85 | return; 86 | } 87 | if (empty($path)) { 88 | $path = $item->label; 89 | } 90 | ArrayHelper::setValue($this->completeItems, $path, [ 91 | self::COMPLETION_KEY => array_filter($item->toArray(), static function ($v) { 92 | return !is_null($v); 93 | }) 94 | ]); 95 | } 96 | 97 | /** 98 | * @inerhitDoc 99 | */ 100 | public function getCompleteItems(): array 101 | { 102 | return $this->completeItems; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/validators/TwigTemplateValidator.php: -------------------------------------------------------------------------------- 1 | variables['variableName'] = $variableValue; 44 | * } 45 | * ); 46 | * ``` 47 | */ 48 | const EVENT_REGISTER_TWIG_VALIDATOR_VARIABLES = 'registerTwigValidatorVariables'; 49 | 50 | // Public Properties 51 | // ========================================================================= 52 | 53 | /** 54 | * @var array Variables in key => value format that should be available during the template rendering 55 | */ 56 | public $variables = []; 57 | 58 | // Public Methods 59 | // ========================================================================= 60 | 61 | /** 62 | * @inheritdoc 63 | */ 64 | public function validateAttribute($model, $attribute) 65 | { 66 | /** @var Model $model */ 67 | $value = $model->$attribute; 68 | $error = null; 69 | if (!empty($value) && is_string($value)) { 70 | try { 71 | $event = new RegisterTwigValidatorVariablesEvent([ 72 | 'variables' => $this->variables, 73 | ]); 74 | $this->trigger(self::EVENT_REGISTER_TWIG_VALIDATOR_VARIABLES, $event); 75 | Craft::$app->getView()->renderString($value, $event->variables); 76 | } catch (Exception $e) { 77 | $error = Craft::t( 78 | 'twigfield', 79 | 'Error rendering template string -> {error}', 80 | ['error' => $e->getMessage()] 81 | ); 82 | } 83 | } else { 84 | $error = Craft::t('twigfield', 'Is not a string.'); 85 | } 86 | // If there's an error, add it to the model, and log it 87 | if ($error) { 88 | $model->addError($attribute, $error); 89 | Craft::error($error, __METHOD__); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/templates/twigfield.twig: -------------------------------------------------------------------------------- 1 | {% macro includeJs(fieldId, fieldType = "Twigfield", wrapperClass = "", editorOptions = {}, twigfieldOptions = {}, placeholderText = '') %} 2 | {% set editorOptions = editorOptions|merge({ 3 | ariaLabel: (editorOptions.ariaLabel ?? '' ~ 'Twig code is supported.'|t('twigfield'))|trim, 4 | }) %} 5 | {# @var \craft\web\AssetBundle bundle #} 6 | {% set bundle = view.registerAssetBundle("nystudio107\\twigfield\\assetbundles\\twigfield\\TwigfieldAsset") %} 7 | {% css %} 8 | @font-face { 9 | font-family: "codicon-override"; 10 | src: url("{{ bundle.baseUrl }}/fonts/codicon.ttf") format("truetype"); 11 | } 12 | {% endcss %} 13 | {% js at head %} 14 | window.codeEditorBaseAssetsUrl = "{{ bundle.baseUrl }}/"; 15 | {% endjs %} 16 | {% js %} 17 | makeMonacoEditor("{{ (fieldId)|namespaceInputId }}", "{{ fieldType }}", "{{ wrapperClass }}", '{{ editorOptions | json_encode | e('js') }}', '{{ twigfieldOptions | json_encode | e('js') }}', "{{ alias('@codeEditorEndpointUrl') }}", "{{ placeholderText }}"); 18 | {% endjs %} 19 | {% endmacro %} 20 | 21 | {% macro textareaField(config, fieldType = "Twigfield", wrapperClass = "", editorOptions = {}, twigfieldOptions = {}) %} 22 | {% import "_includes/forms" as forms %} 23 | {% set config = config|merge({id: config.id ?? "twigfield#{random()}"}) %} 24 | {{ forms.textareaField(config) }} 25 | {% set twigfieldOptions = { 26 | singleLineEditor: false, 27 | } | merge(twigfieldOptions) %} 28 | {{ _self.includeJs(config.id, fieldType, wrapperClass, editorOptions, twigfieldOptions, config.placeholder ?? '') }} 29 | {% endmacro %} 30 | 31 | {% macro textarea(config, fieldType = "Twigfield", wrapperClass = "", editorOptions = {}, twigfieldOptions = {}) %} 32 | {% import "_includes/forms" as forms %} 33 | {% set config = config|merge({id: config.id ?? "twigfield#{random()}"}) %} 34 | {{ forms.textarea(config) }} 35 | {% set twigfieldOptions = { 36 | singleLineEditor: false, 37 | } | merge(twigfieldOptions) %} 38 | {{ _self.includeJs(config.id, fieldType, wrapperClass, editorOptions, twigfieldOptions, config.placeholder ?? '') }} 39 | {% endmacro %} 40 | 41 | {% macro textField(config, fieldType = "Twigfield", wrapperClass = "", editorOptions = {}, twigfieldOptions = {}) %} 42 | {% import "_includes/forms" as forms %} 43 | {% set config = config|merge({id: config.id ?? "twigfield#{random()}"}) %} 44 | {{ forms.textField(config) }} 45 | {% set twigfieldOptions = { 46 | singleLineEditor: true, 47 | } | merge(twigfieldOptions) %} 48 | {{ _self.includeJs(config.id, fieldType, wrapperClass, editorOptions, twigfieldOptions, config.placeholder ?? '') }} 49 | {% endmacro %} 50 | 51 | {% macro text(config, fieldType = "Twigfield", wrapperClass = "", editorOptions = {}, twigfieldOptions = {}) %} 52 | {% import "_includes/forms" as forms %} 53 | {% set config = config|merge({id: config.id ?? "twigfield#{random()}"}) %} 54 | {{ forms.text(config) }} 55 | {% set twigfieldOptions = { 56 | singleLineEditor: true, 57 | } | merge(twigfieldOptions) %} 58 | {{ _self.includeJs(config.id, fieldType, wrapperClass, editorOptions, twigfieldOptions, config.placeholder ?? '') }} 59 | {% endmacro %} 60 | -------------------------------------------------------------------------------- /src/validators/TwigObjectTemplateValidator.php: -------------------------------------------------------------------------------- 1 | object = $myObject; 44 | * $event->variables['variableName'] = $variableValue; 45 | * } 46 | * ); 47 | * ``` 48 | */ 49 | const EVENT_REGISTER_TWIG_VALIDATOR_VARIABLES = 'registerTwigValidatorVariables'; 50 | 51 | /** 52 | * @var mixed The object that should be passed into `renderObjectTemplate()` during the template rendering 53 | */ 54 | public $object = null; 55 | 56 | /** 57 | * @var array Variables in key => value format that should be available during the template rendering 58 | */ 59 | public $variables = []; 60 | 61 | // Public Methods 62 | // ========================================================================= 63 | 64 | /** 65 | * @inheritdoc 66 | */ 67 | public function validateAttribute($model, $attribute) 68 | { 69 | /** @var Model $model */ 70 | $value = $model->$attribute; 71 | $error = null; 72 | if (!empty($value) && is_string($value)) { 73 | try { 74 | $event = new RegisterTwigValidatorVariablesEvent([ 75 | 'object' => $this->object, 76 | 'variables' => $this->variables, 77 | ]); 78 | $this->trigger(self::EVENT_REGISTER_TWIG_VALIDATOR_VARIABLES, $event); 79 | Craft::$app->getView()->renderObjectTemplate($value, $event->object, $event->variables); 80 | } catch (Exception $e) { 81 | $error = Craft::t( 82 | 'twigfield', 83 | 'Error rendering template string -> {error}', 84 | ['error' => $e->getMessage()] 85 | ); 86 | } 87 | } else { 88 | $error = Craft::t('twigfield', 'Is not a string.'); 89 | } 90 | // If there's an error, add it to the model, and log it 91 | if ($error) { 92 | $model->addError($attribute, $error); 93 | Craft::error($error, __METHOD__); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/web/assets/src/css/code-editor.pcss: -------------------------------------------------------------------------------- 1 | /** 2 | * This injects all of Tailwind's utility classes, generated based on your 3 | * config file. 4 | * 5 | */ 6 | @import "tailwindcss/utilities"; 7 | 8 | /* Ensure the right font-family is used */ 9 | .codicon[class*="codicon-"] { 10 | font-family: "codicon-override" !important; 11 | } 12 | 13 | /* Disable the ruler overview since we don't scroll */ 14 | .decorationsOverviewRuler { 15 | display: none !important; 16 | } 17 | 18 | /* For the placeholder text */ 19 | .monaco-placeholder { 20 | display: none; 21 | position: absolute; 22 | top: 0; 23 | left: 0; 24 | pointer-events: none; 25 | z-index: 1; 26 | opacity: 0.7; 27 | } 28 | 29 | /* Get rid of the editor background 30 | .monaco-editor, .monaco-editor-background, .monaco-editor .inputarea.ime-input { 31 | background-color: transparent !important; 32 | } 33 | */ 34 | 35 | /* For an inline editor in a table cell (or elsewhere that no chrome is desired) in the CP */ 36 | .monaco-editor-inline-frame { 37 | background-clip: padding-box; 38 | text-align: left; 39 | } 40 | 41 | /* Editor a11y focus styles */ 42 | .monaco-editor-inline-frame:focus-within { 43 | box-shadow: 0 0 0 1px #127fbf, 0 0 0 3px rgb(18 127 191 / 50%); 44 | } 45 | 46 | /* Remove padding from codefield cells in table fields, so the a11y focus is the entire cell */ 47 | td.type-channel.type-structure.codefield-cell { 48 | padding: 0; 49 | } 50 | 51 | /* Make the editor look like a Craft text field */ 52 | .monaco-editor-background-frame { 53 | border-radius: 3px; 54 | border: 1px solid rgba(96, 125, 159, .25); 55 | background-color: #fbfcfe; 56 | background-clip: padding-box; 57 | margin-top: 5px; 58 | margin-bottom: 5px; 59 | } 60 | 61 | /* Editor a11y focus styles */ 62 | .monaco-editor-background-frame:focus-within { 63 | box-shadow: 0 0 0 1px #127fbf, 0 0 0 3px rgb(18 127 191 / 50%); 64 | } 65 | 66 | .monaco-editor-codefield { 67 | padding: 6px 9px; 68 | background-color: var(--vscode-editor-background, #fffffe); 69 | } 70 | 71 | body.ltr .monaco-editor-codefield { 72 | padding-right: 32px !important; 73 | } 74 | 75 | body.rtl .monaco-editor-codefield { 76 | padding-left: 32px !important; 77 | } 78 | 79 | table.editable tbody .monaco-editor-codefield { 80 | padding: 7px 10px; 81 | } 82 | 83 | /* Code icon */ 84 | .monaco-editor-codefield--icon { 85 | display: flex; 86 | align-items: center; 87 | justify-content: center; 88 | position: absolute; 89 | top: 7px; 90 | width: 18px; 91 | height: 18px; 92 | border: 1px solid var(--indicator-border-color, #cb6e17); 93 | border-radius: var(--small-border-radius, 3px); 94 | color: var(--indicator-icon-color, #bd5a14); 95 | } 96 | 97 | table.editable tbody .monaco-editor-codefield--icon { 98 | top: 9px; 99 | } 100 | 101 | body.ltr .monaco-editor-codefield--icon { 102 | right: 7px; 103 | } 104 | 105 | body.rtl .monaco-editor-codefield--icon { 106 | left: 7px; 107 | } 108 | 109 | /* Make the suggestion-details a more reasonable default size */ 110 | .monaco-editor .suggest-details { 111 | } 112 | 113 | .monaco-editor .suggest-details .body p { 114 | } 115 | 116 | .monaco-editor .suggest-details .body { 117 | } 118 | 119 | .monaco-editor .suggest-details .header p { 120 | } 121 | 122 | .monaco-editor .suggest-details .header { 123 | } 124 | 125 | /* CSS for use with dark-themes like Night Owl 126 | 127 | .monaco-mouse-cursor-text:focus, 128 | .monaco-mouse-cursor-text:focus-visible { 129 | box-shadow: none!important; 130 | } 131 | 132 | .monaco-scrollable-element { 133 | background-color: #011627; 134 | } 135 | 136 | .monaco-editor .suggest-widget, .monaco-editor .suggest-details { 137 | background-color: #011627; 138 | border-color: #112637!important; 139 | border: 1px solid #112637!important; 140 | } 141 | 142 | .monaco-editor-background-frame { 143 | background-color: #011627; 144 | } 145 | 146 | .monaco-editor .suggest-details .header { 147 | background-color: #112637; 148 | } 149 | */ 150 | -------------------------------------------------------------------------------- /src/Twigfield.php: -------------------------------------------------------------------------------- 1 | configureModule(); 79 | // Register our components 80 | $this->registerComponents(); 81 | // Register our event handlers 82 | $this->registerEventHandlers(); 83 | Craft::info('Twigfield module bootstrapped', __METHOD__); 84 | } 85 | 86 | // Protected Methods 87 | // ========================================================================= 88 | 89 | /** 90 | * Configure our module 91 | * 92 | * @return void 93 | */ 94 | protected function configureModule(): void 95 | { 96 | // Set up our alias 97 | Craft::setAlias('@nystudio107/twigfield', $this->getBasePath()); 98 | Craft::setAlias('@codeEditorEndpointUrl', UrlHelper::actionUrl('twigfield/autocomplete/index')); 99 | // Register our module 100 | Craft::$app->setModule($this->id, $this); 101 | // Translation category 102 | $i18n = Craft::$app->getI18n(); 103 | /** @noinspection UnSafeIsSetOverArrayInspection */ 104 | if (!isset($i18n->translations[$this->id]) && !isset($i18n->translations[$this->id . '*'])) { 105 | $i18n->translations[$this->id] = [ 106 | 'class' => PhpMessageSource::class, 107 | 'sourceLanguage' => 'en-US', 108 | 'basePath' => '@nystudio107/twigfield/translations', 109 | 'forceTranslation' => true, 110 | 'allowOverrides' => true, 111 | ]; 112 | } 113 | // Set our settings 114 | $config = Config::getConfigFromFile($this->id); 115 | self::$settings = new Settings($config); 116 | } 117 | 118 | /** 119 | * Registers our components 120 | * 121 | * @return void 122 | */ 123 | public function registerComponents(): void 124 | { 125 | $this->setComponents([ 126 | 'autocomplete' => AutocompleteService::class, 127 | ]); 128 | } 129 | 130 | /** 131 | * Registers our event handlers 132 | * 133 | * @return void 134 | */ 135 | public function registerEventHandlers(): void 136 | { 137 | // Base CP templates directory 138 | Event::on(View::class, View::EVENT_REGISTER_CP_TEMPLATE_ROOTS, function (RegisterTemplateRootsEvent $e) { 139 | if (is_dir($baseDir = $this->getBasePath() . DIRECTORY_SEPARATOR . 'templates')) { 140 | $e->roots[$this->id] = $baseDir; 141 | } 142 | }); 143 | // Base Site templates directory 144 | if (self::$settings->allowFrontendAccess) { 145 | Event::on(View::class, View::EVENT_REGISTER_SITE_TEMPLATE_ROOTS, function (RegisterTemplateRootsEvent $e) { 146 | if (is_dir($baseDir = $this->getBasePath() . DIRECTORY_SEPARATOR . 'templates')) { 147 | $e->roots[$this->id] = $baseDir; 148 | } 149 | }); 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/autocompletes/CraftApiAutocomplete.php: -------------------------------------------------------------------------------- 1 | twigGlobals)) { 79 | $this->twigGlobals = Craft::$app->view->getTwig()->getGlobals(); 80 | } 81 | if (empty($this->elementRouteGlobals)) { 82 | $this->elementRouteGlobals = $this->getElementRouteGlobals(); 83 | } 84 | } 85 | 86 | /** 87 | * @inerhitDoc 88 | */ 89 | public function generateCompleteItems(): void 90 | { 91 | // Gather up all of the globals to parse 92 | $globals = array_merge( 93 | $this->twigGlobals, 94 | $this->elementRouteGlobals, 95 | $this->additionalGlobals, 96 | $this->overrideValues() 97 | ); 98 | foreach ($globals as $key => $value) { 99 | if (!in_array($key, parent::EXCLUDED_PROPERTY_NAMES, true)) { 100 | $type = gettype($value); 101 | switch ($type) { 102 | case 'object': 103 | $this->parseObject($key, $value, 0); 104 | break; 105 | 106 | case 'array': 107 | case 'boolean': 108 | case 'double': 109 | case 'integer': 110 | case 'string': 111 | $kind = CompleteItemKind::VariableKind; 112 | $path = $key; 113 | $normalizedKey = preg_replace("/[^A-Za-z]/", '', $key); 114 | if (ctype_upper($normalizedKey)) { 115 | $kind = CompleteItemKind::ConstantKind; 116 | } 117 | // If this is an array, JSON-encode the keys. In the future, we could recursively parse the array 118 | // To allow for nested values 119 | if (is_array($value)) { 120 | $value = json_encode(array_keys($value)); 121 | } 122 | CompleteItem::create() 123 | ->detail((string)$value) 124 | ->kind($kind) 125 | ->label((string)$key) 126 | ->insertText((string)$key) 127 | ->add($this, $path); 128 | break; 129 | } 130 | } 131 | } 132 | } 133 | 134 | // Protected Methods 135 | // ========================================================================= 136 | 137 | /** 138 | * Add in the element types that could be injected as route variables 139 | * 140 | * @return array 141 | */ 142 | protected function getElementRouteGlobals(): array 143 | { 144 | $routeVariables = []; 145 | $elementTypes = Craft::$app->elements->getAllElementTypes(); 146 | foreach ($elementTypes as $elementType) { 147 | /* @var Element $elementType */ 148 | $key = $elementType::refHandle(); 149 | if (!empty($key) && !in_array($key, self::ELEMENT_ROUTE_EXCLUDES)) { 150 | $routeVariables[$key] = new $elementType(); 151 | } 152 | } 153 | 154 | return $routeVariables; 155 | } 156 | 157 | /** 158 | * Override certain values that we always want hard-coded 159 | * 160 | * @return array 161 | */ 162 | protected function overrideValues(): array 163 | { 164 | return [ 165 | // Set the nonce to a blank string, as it changes on every request 166 | 'nonce' => '', 167 | ]; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/web/assets/src/js/language-icons.ts: -------------------------------------------------------------------------------- 1 | interface StringMap { 2 | [key: string]: string; 3 | } 4 | 5 | export const languageIconTitles: StringMap = { 6 | 'twig': 'Twig code is supported', 7 | 'javascript': 'JavaScript code is supported', 8 | 'markdown': 'Markdown code is supported', 9 | 'typescript': 'TypeScript code is supported', 10 | 'css': 'CSS code is supported', 11 | } 12 | 13 | export const languageIcons: StringMap = { 14 | // Twig 15 | 'twig': ` 16 | 17 | 18 | \t 21 | \t 24 | 25 | `, 26 | // JavaScript 27 | 'javascript': ` 28 | 29 | 30 | 31 | 32 | 33 | `, 34 | // Markdown 35 | 'markdown': ` 36 | 37 | 38 | 39 | `, 40 | // TypeScript 41 | 'typescript': ` 42 | 43 | 44 | 45 | `, 46 | // CSS 47 | 'css': ` 48 | 51 | 52 | 53 | 55 | 58 | 61 | 62 | `, 63 | }; 64 | -------------------------------------------------------------------------------- /src/autocompletes/SectionShorthandFieldsAutocomplete.php: -------------------------------------------------------------------------------- 1 | [ 38 | 'typeId' => "the entry type’s ID", 39 | 'authorId' => "the entry author’s ID", 40 | 'type' => "the entry type", 41 | 'section' => "the entry’s section", 42 | 'author' => "the entry’s author", 43 | ] 44 | ]; 45 | 46 | // Public Properties 47 | // ========================================================================= 48 | 49 | /** 50 | * @var string The name of the autocomplete 51 | */ 52 | public $name = 'SectionShorthandFieldsAutocomplete'; 53 | 54 | /** 55 | * @var string The type of the autocomplete 56 | */ 57 | public $type = AutocompleteTypes::TwigExpressionAutocomplete; 58 | 59 | /** 60 | * @var string Whether the autocomplete should be parsed with . -delimited nested sub-properties 61 | */ 62 | public $hasSubProperties = true; 63 | 64 | /** 65 | * @inheritdoc 66 | */ 67 | public $parseBehaviors = false; 68 | 69 | /** 70 | * @inheritdoc 71 | */ 72 | public $parseMethods = false; 73 | 74 | /** 75 | * @inheritdoc 76 | */ 77 | public $customPropertySortPrefix = ''; 78 | 79 | /** 80 | * @inheritdoc 81 | */ 82 | public $propertySortPrefix = ''; 83 | 84 | /** 85 | * @inheritdoc 86 | */ 87 | public $methodSortPrefix = ''; 88 | 89 | /** 90 | * @var ?int The section id. A sectionId of 0 denotes we don't know what this section is, so use 91 | * a generic `Entry` and don't generate any complete items for custom fields 92 | */ 93 | public $sectionId = null; 94 | 95 | // Public Methods 96 | // ========================================================================= 97 | 98 | /** 99 | * @inerhitDoc 100 | */ 101 | public function init(): void 102 | { 103 | $this->sectionId = $this->twigfieldOptions[self::OPTIONS_DATA_KEY] ?? null; 104 | } 105 | 106 | /** 107 | * Core function that generates the autocomplete array 108 | */ 109 | public function generateCompleteItems(): void 110 | { 111 | if ($this->sectionId !== null) { 112 | // A sectionId of 0 denotes we don't know what this section is, so use 113 | // a generic `Entry` and don't generate any complete items for custom fields 114 | if ($this->sectionId === 0) { 115 | $element = new Entry(); 116 | $this->parseObject('', $element, 0); 117 | $this->addMagicGetterProperties($element); 118 | 119 | return; 120 | } 121 | // Find the entry types used in the passed in sectionId 122 | $sections = Craft::$app->getSections(); 123 | $section = $sections->getSectionById($this->sectionId); 124 | if ($section) { 125 | $entryTypes = $section->getEntryTypes(); 126 | foreach ($entryTypes as $entryType) { 127 | // Add the native fields in 128 | if ($entryType->elementType) { 129 | $element = new $entryType->elementType; 130 | /* @var ElementInterface $element */ 131 | $this->parseObject('', $element, 0); 132 | $this->addMagicGetterProperties($element); 133 | } 134 | // Add the custom fields in 135 | if (version_compare(Craft::$app->getVersion(), '4.0', '>=')) { 136 | $customFields = $entryType->getCustomFields(); 137 | } else { 138 | $customFields = $entryType->getFields(); 139 | } 140 | foreach ($customFields as $customField) { 141 | $name = $customField->handle; 142 | $docs = $customField->instructions ?? ''; 143 | if ($name) { 144 | CompleteItem::create() 145 | ->insertText($name) 146 | ->label($name) 147 | ->detail(Craft::t('twigfield', 'Custom Field Shorthand')) 148 | ->documentation($docs) 149 | ->kind(CompleteItemKind::FieldKind) 150 | ->add($this); 151 | } 152 | } 153 | } 154 | } 155 | } 156 | } 157 | 158 | /** 159 | * Add in magic getter properties that are defined only in the `@property` docblock annotation 160 | * 161 | * @param ElementInterface $element 162 | * @return void 163 | */ 164 | protected function addMagicGetterProperties(ElementInterface $element): void 165 | { 166 | foreach (self::MAGIC_GETTER_PROPERTIES as $key => $value) { 167 | if ($key === $element::class) { 168 | foreach ($value as $name => $docs) { 169 | CompleteItem::create() 170 | ->insertText($name) 171 | ->label($name) 172 | ->detail(Craft::t('twigfield', 'Custom Field Shorthand')) 173 | ->documentation($docs) 174 | ->kind(CompleteItemKind::FieldKind) 175 | ->add($this); 176 | } 177 | } 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Craft Twigfield Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | # DEPRECATED 6 | 7 | Twigfield is now deprecated; please use [nystudio107/craft-code-editor](https://github.com/nystudio107/craft-code-editor) instead, which is a general purpose code editor that also does Twig & autocompletes. 8 | 9 | ## 1.0.20 - UNRELEASED 10 | ### Added 11 | * Added all language workers to the build / bundle process 12 | 13 | ### Changed 14 | * Refactored `twigfield.js` to TypeScript 15 | * Move the language icons to a separate `language-icons.ts` file 16 | * Remove the transparent background CSS style to allow for theming 17 | 18 | ## 1.0.19 - 2022.10.26 19 | ### Fixed 20 | * Fixed an issue that didn't properly encode `twigFieldOptions` for a JavaScript context, resulting in a broken field in some cases ([#1225](https://github.com/nystudio107/craft-seomatic/issues/1225)) 21 | 22 | ## 1.0.18 - 2022.10.25 23 | ### Added 24 | * Manually handle Tab & Shift-Tab for single line Twigfields to allow tabbing to other fields in a form 25 | 26 | ## 1.0.17 - 2022.10.23 27 | ### Added 28 | * Added a better Twig indicator icon, along with a `title` attribute for a tooltip indicator, and #a11y improvements ([#5](https://github.com/nystudio107/craft-twigfield/pull/5)) 29 | 30 | ### Changed 31 | * Set both `alwaysConsumeMouseWheel` & `handleMouseWheel` to `false` in the default Monaco Editor config to avoid it consuming mouse wheel events that prevent scrolling pages with Twigfield fields ref: ([#1853](https://github.com/microsoft/monaco-editor/issues/1853)) 32 | 33 | ## 1.0.16 - 2022.10.18 34 | ### Changed 35 | * Moved `craftcms/cms` to `require-dev` 36 | 37 | ## 1.0.15 - 2022.10.17 38 | ### Fixed 39 | * Fixed an issue that caused Twigfield to throw an exception if you were running < PHP 8 ([#1220](https://github.com/nystudio107/craft-seomatic/issues/1220)) 40 | 41 | ## 1.0.14 - 2022.10.13 42 | ### Fixed 43 | * Fixed an issue where `getCustomFields()` was being called in Craft 3, where it doesn't exist 44 | 45 | ## 1.0.13 - 2022.10.13 46 | ### Added 47 | * Added `monaco-editor-inline-frame` built-in style for an inline editor in a table cell (or elsewhere that no chrome is desired) 48 | * Added `SectionShorthandFieldsAutocomplete` to provide shorthand autocomplete items for Craft sections 49 | * Added conditionals to the `ObjectParserAutocomplete` abstract class so that child classes can determine exactly what gets parsed by overriding properties 50 | * Added the ability to have placeholder text for the Twigfield editor 51 | * Allow the Twig environment to be passed down to the `TwigLanguageAutocomplete` Autocomplete via DI 52 | * Change constants to properties for the sort prefixes in `ObjectParserAutocomplete` to allow child classes to override the settings 53 | 54 | ### Changed 55 | * Invalidate `SectionShorthandFieldsAutocomplete` caches whenever any field layout is edited 56 | * Add in magic getter properties that are defined only in the `@property` docblock annotation 57 | 58 | ## 1.0.12 - 2022.10.04 59 | ### Added 60 | * Add `ObjectAutocomplete` class to allow for easily adding all of the properties of an object as autocomplete items 61 | * Add missing Twig tags `else`, `elseif`, `endblock` & `endif` 62 | * Allow the `twigfieldOptions` config object to be passed into the Twig macros 63 | * Include a hash of the `twigfieldOptions` in the cache key used for the autocomplete 64 | 65 | ### Changed 66 | * Refactor to `ObjectParserAutocomplete` & `ObjectParserInterface` 67 | 68 | ## 1.0.11 - 2022.08.24 69 | ### Changed 70 | * Remove `FluentModel` class and replace the magic method setter with fluent setter methods in `CompleteItem` 71 | 72 | ## 1.0.10 - 2022.08.23 73 | ### Changed 74 | * Add `allow-plugins` to `composer.json` so CI can work 75 | 76 | ### Fixed 77 | * Fixed an issue where an exception could be thrown during the bootstrap process in earlier versions of Yii2 due to `$id` not being set 78 | 79 | ## 1.0.9 - 2022.06.24 80 | ### Fixed 81 | * Instead of attempting to convert an array into a string, JSON-encode the keys of the array for the value 82 | 83 | ## 1.0.8 - 2022.06.23 84 | ### Fixed 85 | * Fixed an issue that could cause an exception to be thrown after first install/update to a plugin that uses Twigfield, which prevented the module from loading ([#2](https://github.com/nystudio107/craft-twigfield/issues/2)) ([#1161](https://github.com/nystudio107/craft-seomatic/issues/1161)) 86 | 87 | ## 1.0.7 - 2022.06.22 88 | ### Fixed 89 | * Fixed an issue that could cause the autocomplete endpoint to 404 if the `actionUrl` already contains URL parameters ([#1](https://github.com/nystudio107/craft-twigfield/pull/1)) 90 | 91 | ## 1.0.6 - 2022.06.21 92 | ### Changed 93 | * Better handling of object property docblocks in the `CraftApiAutocomplete` 94 | * The `CraftApiAutocomplete` now typecasts properties to `string` to ensure they validate 95 | 96 | ## 1.0.5 - 2022.06.21 97 | ### Changed 98 | * Only issue an XHR for autocomplete items of the specified `fieldType` if they haven't been added already, for better performance with multiple Twigfield instances on a single page 99 | 100 | ## 1.0.4 - 2022.06.20 101 | ### Changed 102 | * Handle cases where there is no space between the `{{` opening brackets of a Twig expression so nested properties autocomplete there, too 103 | * Sort environment variables below other autocompletes 104 | * Tweak the CSS to allow it to fit into the encompassing `
` better 105 | 106 | ## 1.0.3 - 2022.06.18 107 | ### Added 108 | * Added the ability to pass in a config array to autocomplete classes via the `AutocompleteService::EVENT_REGISTER_TWIGFIELD_AUTOCOMPLETES` event 109 | * Added the `$hasSubProperties` property to the Autocomplete model, to indicate whether the autocomplete returns nested sub-properties such as `foo.bar.baz` 110 | * Added the ability to pass in the `twigGlobals` & `elementRouteGlobals` properties via dependency injection to the `CraftApiAutocomplete` autocomplete 111 | 112 | ### Changed 113 | * Removed errant logging 114 | 115 | ## 1.0.2 - 2022.06.15 116 | ### Fixed 117 | * Fixed an issue where autocomplete of nested properties wouldn't work if there was no space after a `{` in Twig 118 | * Fixed an issue where `GeneralAutocompletes` were applied when we were in a sub-property, which resulted in JS errors 119 | 120 | ## 1.0.1 - 2022.06.13 121 | ### Added 122 | * Added `text()` and `textField()` macros that create a single-line Twig editor for simple Twig expressions 123 | * Added `$additionalGlobals` to the `CraftApiAutocomplete` class so that classes extending it can add their own global variables to be parsed for autocomplete items 124 | 125 | ## 1.0.0 - 2022.06.13 126 | ### Added 127 | * Initial release 128 | -------------------------------------------------------------------------------- /src/web/assets/dist/js/runtime.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"js/runtime.js","mappings":";;;;;;;gCAAIA,E,KCCAC,EAA2B,CAAC,EAGhC,SAASC,EAAoBC,GAE5B,IAAIC,EAAeH,EAAyBE,GAC5C,QAAqBE,IAAjBD,EACH,OAAOA,EAAaE,QAGrB,IAAIC,EAASN,EAAyBE,GAAY,CAGjDG,QAAS,CAAC,GAOX,OAHAE,EAAoBL,GAAUI,EAAQA,EAAOD,QAASJ,GAG/CK,EAAOD,OACf,CAGAJ,EAAoBO,EAAID,ECzBxBN,EAAoBQ,KAAO,CAAC,EFAxBV,EAAW,GACfE,EAAoBS,EAAI,SAASC,EAAQC,EAAUC,EAAIC,GACtD,IAAGF,EAAH,CAMA,IAAIG,EAAeC,IACnB,IAASC,EAAI,EAAGA,EAAIlB,EAASmB,OAAQD,IAAK,CACrCL,EAAWb,EAASkB,GAAG,GACvBJ,EAAKd,EAASkB,GAAG,GACjBH,EAAWf,EAASkB,GAAG,GAE3B,IAJA,IAGIE,GAAY,EACPC,EAAI,EAAGA,EAAIR,EAASM,OAAQE,MACpB,EAAXN,GAAsBC,GAAgBD,IAAaO,OAAOC,KAAKrB,EAAoBS,GAAGa,OAAM,SAASC,GAAO,OAAOvB,EAAoBS,EAAEc,GAAKZ,EAASQ,GAAK,IAChKR,EAASa,OAAOL,IAAK,IAErBD,GAAY,EACTL,EAAWC,IAAcA,EAAeD,IAG7C,GAAGK,EAAW,CACbpB,EAAS0B,OAAOR,IAAK,GACrB,IAAIS,EAAIb,SACET,IAANsB,IAAiBf,EAASe,EAC/B,CACD,CACA,OAAOf,CArBP,CAJCG,EAAWA,GAAY,EACvB,IAAI,IAAIG,EAAIlB,EAASmB,OAAQD,EAAI,GAAKlB,EAASkB,EAAI,GAAG,GAAKH,EAAUG,IAAKlB,EAASkB,GAAKlB,EAASkB,EAAI,GACrGlB,EAASkB,GAAK,CAACL,EAAUC,EAAIC,EAwB/B,EG5BAb,EAAoB0B,EAAI,SAAStB,EAASuB,GACzC,IAAI,IAAIJ,KAAOI,EACX3B,EAAoB4B,EAAED,EAAYJ,KAASvB,EAAoB4B,EAAExB,EAASmB,IAC5EH,OAAOS,eAAezB,EAASmB,EAAK,CAAEO,YAAY,EAAMC,IAAKJ,EAAWJ,IAG3E,ECJAvB,EAAoBgC,EAAI,WAAa,OAAOC,QAAQC,SAAW,ECH/DlC,EAAoBmC,EAAI,WACvB,GAA0B,iBAAfC,WAAyB,OAAOA,WAC3C,IACC,OAAOC,MAAQ,IAAIC,SAAS,cAAb,EAGhB,CAFE,MAAON,GACR,GAAsB,iBAAXO,OAAqB,OAAOA,MACxC,CACA,CAPuB,GCAxBvC,EAAoB4B,EAAI,SAASY,EAAKC,GAAQ,OAAOrB,OAAOsB,UAAUC,eAAeC,KAAKJ,EAAKC,EAAO,ECCtGzC,EAAoByB,EAAI,SAASrB,GACX,oBAAXyC,QAA0BA,OAAOC,aAC1C1B,OAAOS,eAAezB,EAASyC,OAAOC,YAAa,CAAEC,MAAO,WAE7D3B,OAAOS,eAAezB,EAAS,aAAc,CAAE2C,OAAO,GACvD,ECNA/C,EAAoBgD,EAAI,G,WCKxB,IAAIC,EAAkB,CACrB,IAAK,EACL,IAAK,GAaNjD,EAAoBS,EAAEU,EAAI,SAAS+B,GAAW,OAAoC,IAA7BD,EAAgBC,EAAgB,EAGrF,IAAIC,EAAuB,SAASC,EAA4BC,GAC/D,IAKIpD,EAAUiD,EALVvC,EAAW0C,EAAK,GAChBC,EAAcD,EAAK,GACnBE,EAAUF,EAAK,GAGIrC,EAAI,EAC3B,GAAGL,EAAS6C,MAAK,SAASC,GAAM,OAA+B,IAAxBR,EAAgBQ,EAAW,IAAI,CACrE,IAAIxD,KAAYqD,EACZtD,EAAoB4B,EAAE0B,EAAarD,KACrCD,EAAoBO,EAAEN,GAAYqD,EAAYrD,IAGhD,GAAGsD,EAAS,IAAI7C,EAAS6C,EAAQvD,EAClC,CAEA,IADGoD,GAA4BA,EAA2BC,GACrDrC,EAAIL,EAASM,OAAQD,IACzBkC,EAAUvC,EAASK,GAChBhB,EAAoB4B,EAAEqB,EAAiBC,IAAYD,EAAgBC,IACrED,EAAgBC,GAAS,KAE1BD,EAAgBC,GAAW,EAE5B,OAAOlD,EAAoBS,EAAEC,EAC9B,EAEIgD,EAAqBC,KAA6B,uBAAIA,KAA6B,wBAAK,GAC5FD,EAAmBE,QAAQT,EAAqBU,KAAK,KAAM,IAC3DH,EAAmBI,KAAOX,EAAqBU,KAAK,KAAMH,EAAmBI,KAAKD,KAAKH,G","sources":["webpack://Buildchain/webpack/runtime/chunk loaded","webpack://Buildchain/webpack/bootstrap","webpack://Buildchain/webpack/runtime/amd options","webpack://Buildchain/webpack/runtime/define property getters","webpack://Buildchain/webpack/runtime/ensure chunk","webpack://Buildchain/webpack/runtime/global","webpack://Buildchain/webpack/runtime/hasOwnProperty shorthand","webpack://Buildchain/webpack/runtime/make namespace object","webpack://Buildchain/webpack/runtime/publicPath","webpack://Buildchain/webpack/runtime/jsonp chunk loading"],"sourcesContent":["var deferred = [];\n__webpack_require__.O = function(result, chunkIds, fn, priority) {\n\tif(chunkIds) {\n\t\tpriority = priority || 0;\n\t\tfor(var i = deferred.length; i > 0 && deferred[i - 1][2] > priority; i--) deferred[i] = deferred[i - 1];\n\t\tdeferred[i] = [chunkIds, fn, priority];\n\t\treturn;\n\t}\n\tvar notFulfilled = Infinity;\n\tfor (var i = 0; i < deferred.length; i++) {\n\t\tvar chunkIds = deferred[i][0];\n\t\tvar fn = deferred[i][1];\n\t\tvar priority = deferred[i][2];\n\t\tvar fulfilled = true;\n\t\tfor (var j = 0; j < chunkIds.length; j++) {\n\t\t\tif ((priority & 1 === 0 || notFulfilled >= priority) && Object.keys(__webpack_require__.O).every(function(key) { return __webpack_require__.O[key](chunkIds[j]); })) {\n\t\t\t\tchunkIds.splice(j--, 1);\n\t\t\t} else {\n\t\t\t\tfulfilled = false;\n\t\t\t\tif(priority < notFulfilled) notFulfilled = priority;\n\t\t\t}\n\t\t}\n\t\tif(fulfilled) {\n\t\t\tdeferred.splice(i--, 1)\n\t\t\tvar r = fn();\n\t\t\tif (r !== undefined) result = r;\n\t\t}\n\t}\n\treturn result;\n};","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n// expose the modules object (__webpack_modules__)\n__webpack_require__.m = __webpack_modules__;\n\n","__webpack_require__.amdO = {};","// define getter functions for harmony exports\n__webpack_require__.d = function(exports, definition) {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","// The chunk loading function for additional chunks\n// Since all referenced chunks are already included\n// in this file, this function is empty here.\n__webpack_require__.e = function() { return Promise.resolve(); };","__webpack_require__.g = (function() {\n\tif (typeof globalThis === 'object') return globalThis;\n\ttry {\n\t\treturn this || new Function('return this')();\n\t} catch (e) {\n\t\tif (typeof window === 'object') return window;\n\t}\n})();","__webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }","// define __esModule on exports\n__webpack_require__.r = function(exports) {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","__webpack_require__.p = \"\";","// no baseURI\n\n// object to store loaded and loading chunks\n// undefined = chunk not loaded, null = chunk preloaded/prefetched\n// [resolve, reject, Promise] = chunk loading, 0 = chunk loaded\nvar installedChunks = {\n\t666: 0,\n\t532: 0\n};\n\n// no chunk on demand loading\n\n// no prefetching\n\n// no preloaded\n\n// no HMR\n\n// no HMR manifest\n\n__webpack_require__.O.j = function(chunkId) { return installedChunks[chunkId] === 0; };\n\n// install a JSONP callback for chunk loading\nvar webpackJsonpCallback = function(parentChunkLoadingFunction, data) {\n\tvar chunkIds = data[0];\n\tvar moreModules = data[1];\n\tvar runtime = data[2];\n\t// add \"moreModules\" to the modules object,\n\t// then flag all \"chunkIds\" as loaded and fire callback\n\tvar moduleId, chunkId, i = 0;\n\tif(chunkIds.some(function(id) { return installedChunks[id] !== 0; })) {\n\t\tfor(moduleId in moreModules) {\n\t\t\tif(__webpack_require__.o(moreModules, moduleId)) {\n\t\t\t\t__webpack_require__.m[moduleId] = moreModules[moduleId];\n\t\t\t}\n\t\t}\n\t\tif(runtime) var result = runtime(__webpack_require__);\n\t}\n\tif(parentChunkLoadingFunction) parentChunkLoadingFunction(data);\n\tfor(;i < chunkIds.length; i++) {\n\t\tchunkId = chunkIds[i];\n\t\tif(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {\n\t\t\tinstalledChunks[chunkId][0]();\n\t\t}\n\t\tinstalledChunks[chunkId] = 0;\n\t}\n\treturn __webpack_require__.O(result);\n}\n\nvar chunkLoadingGlobal = self[\"webpackChunkBuildchain\"] = self[\"webpackChunkBuildchain\"] || [];\nchunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));\nchunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));"],"names":["deferred","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","undefined","exports","module","__webpack_modules__","m","amdO","O","result","chunkIds","fn","priority","notFulfilled","Infinity","i","length","fulfilled","j","Object","keys","every","key","splice","r","d","definition","o","defineProperty","enumerable","get","e","Promise","resolve","g","globalThis","this","Function","window","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","p","installedChunks","chunkId","webpackJsonpCallback","parentChunkLoadingFunction","data","moreModules","runtime","some","id","chunkLoadingGlobal","self","forEach","bind","push"],"sourceRoot":""} -------------------------------------------------------------------------------- /src/services/AutocompleteService.php: -------------------------------------------------------------------------------- 1 | types[] = MyAutocomplete::class; 53 | * } 54 | * ); 55 | * 56 | * or to pass in a config array for the Autocomplete object: 57 | * 58 | * Event::on(AutocompleteService::class, 59 | * AutocompleteService::EVENT_REGISTER_TWIGFIELD_AUTOCOMPLETES, 60 | * function(RegisterTwigfieldAutocompletesEvent $event) { 61 | * $config = [ 62 | * 'property' => value, 63 | * ]; 64 | * $event->types[] = [MyAutocomplete::class => $config]; 65 | * } 66 | * ); 67 | * 68 | * ``` 69 | */ 70 | const EVENT_REGISTER_TWIGFIELD_AUTOCOMPLETES = 'registerTwigfieldAutocompletes'; 71 | 72 | const AUTOCOMPLETE_CACHE_TAG = 'TwigFieldAutocompleteTag'; 73 | 74 | // Public Properties 75 | // ========================================================================= 76 | 77 | /** 78 | * @var string Prefix for the cache key 79 | */ 80 | public $cacheKeyPrefix = 'TwigFieldAutocomplete'; 81 | 82 | /** 83 | * @var int Cache duration 84 | */ 85 | public $cacheDuration = 3600; 86 | 87 | // Public Methods 88 | // ========================================================================= 89 | 90 | /** 91 | * @inerhitDoc 92 | */ 93 | public function init(): void 94 | { 95 | parent::init(); 96 | // Short cacheDuration if we're in devMode 97 | if (Craft::$app->getConfig()->getGeneral()->devMode) { 98 | $this->cacheDuration = 1; 99 | } 100 | // Invalidate any SectionShorthandFieldsAutocomplete caches whenever any field layout is edited 101 | Event::on(Fields::class, Fields::EVENT_AFTER_SAVE_FIELD_LAYOUT, function (SectionEvent $e) { 102 | $this->clearAutocompleteCache(SectionShorthandFieldsAutocomplete::class); 103 | }); 104 | } 105 | 106 | /** 107 | * Call each of the autocompletes to generate their complete items 108 | * @param string $fieldType 109 | * @param array $twigfieldOptions 110 | * @return array 111 | */ 112 | public function generateAutocompletes(string $fieldType = Twigfield::DEFAULT_FIELD_TYPE, array $twigfieldOptions = []): array 113 | { 114 | $autocompleteItems = []; 115 | $autocompletes = $this->getAllAutocompleteGenerators($fieldType); 116 | foreach ($autocompletes as $autocompleteGenerator) { 117 | /* @var BaseAutoComplete $autocomplete */ 118 | // Assume the generator is a class name string 119 | $config = [ 120 | 'fieldType' => $fieldType, 121 | 'twigfieldOptions' => $twigfieldOptions, 122 | ]; 123 | $autocompleteClass = $autocompleteGenerator; 124 | // If we're passed in an array instead, extract the class name and config from the key/value pair 125 | // in the form of [className => configArray] 126 | if (is_array($autocompleteGenerator)) { 127 | $autocompleteClass = array_key_first($autocompleteGenerator); 128 | /** @noinspection SlowArrayOperationsInLoopInspection */ 129 | $config = array_merge($config, $autocompleteGenerator[$autocompleteClass]); 130 | } 131 | $autocomplete = new $autocompleteClass($config); 132 | $name = $autocomplete->name; 133 | // Set up the cache parameters 134 | $cache = Craft::$app->getCache(); 135 | $cacheKey = $this->getAutocompleteCacheKey($autocomplete, $config); 136 | $dependency = new TagDependency([ 137 | 'tags' => [ 138 | self::AUTOCOMPLETE_CACHE_TAG, 139 | self::AUTOCOMPLETE_CACHE_TAG . $name, 140 | self::AUTOCOMPLETE_CACHE_TAG . get_class($autocomplete), 141 | ], 142 | ]); 143 | // Get the autocompletes from the cache, or generate them if they aren't cached 144 | $autocompleteItems[$name] = $cache->getOrSet($cacheKey, static function () use ($name, $autocomplete) { 145 | $autocomplete->generateCompleteItems(); 146 | return [ 147 | 'name' => $name, 148 | 'type' => $autocomplete->type, 149 | 'hasSubProperties' => $autocomplete->hasSubProperties, 150 | BaseAutoComplete::COMPLETION_KEY => $autocomplete->getCompleteItems(), 151 | ]; 152 | }, $this->cacheDuration, $dependency); 153 | } 154 | Craft::info('Twigfield Autocompletes generated', __METHOD__); 155 | 156 | return $autocompleteItems; 157 | } 158 | 159 | /** 160 | * Clear the specified autocomplete cache (or all autocomplete caches if left empty) 161 | * 162 | * @param string $autocompleteName 163 | * @return void 164 | */ 165 | public function clearAutocompleteCache(string $autocompleteName = ''): void 166 | { 167 | $cache = Craft::$app->getCache(); 168 | TagDependency::invalidate($cache, self::AUTOCOMPLETE_CACHE_TAG . $autocompleteName); 169 | Craft::info('Twigfield caches invalidated', __METHOD__); 170 | } 171 | 172 | /** 173 | * Return the cache key to use for an Autocomplete's complete items 174 | * 175 | * @param AutocompleteInterface $autocomplete 176 | * @param array $config 177 | * @return string 178 | */ 179 | public function getAutocompleteCacheKey(AutocompleteInterface $autocomplete, array $config): string 180 | { 181 | return $this->cacheKeyPrefix . $autocomplete->name . md5(serialize($config)); 182 | } 183 | 184 | // Protected Methods 185 | // ========================================================================= 186 | 187 | /** 188 | * Returns all available autocompletes classes. 189 | * 190 | * @return string[] The available autocompletes classes 191 | */ 192 | public function getAllAutocompleteGenerators(string $fieldType = Twigfield::DEFAULT_FIELD_TYPE): array 193 | { 194 | $event = new RegisterTwigfieldAutocompletesEvent([ 195 | 'types' => Twigfield::$settings->defaultTwigfieldAutocompletes, 196 | 'fieldType' => $fieldType, 197 | ]); 198 | $this->trigger(self::EVENT_REGISTER_TWIGFIELD_AUTOCOMPLETES, $event); 199 | 200 | return $event->types; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/models/CompleteItem.php: -------------------------------------------------------------------------------- 1 | additionalTextEdits = $value; 136 | return $this; 137 | } 138 | 139 | /** 140 | * A command that should be run upon acceptance of this item. 141 | * ref: https://microsoft.github.io/monaco-editor/api/interfaces/monaco.languages.Command.html 142 | * @param $value array 143 | */ 144 | public function command($value): self 145 | { 146 | $this->command = $value; 147 | return $this; 148 | } 149 | 150 | /** 151 | * An optional set of characters that when pressed while this completion is active will accept 152 | * it first and then type that character. Note that all commit characters should have `length=1` and that 153 | * superfluous characters will be ignored. 154 | * @param $value array 155 | */ 156 | public function commitCharacters($value): self 157 | { 158 | $this->commitCharacters = $value; 159 | return $this; 160 | } 161 | 162 | /** 163 | * A human-readable string with additional information about this item, like type or symbol information. 164 | * @param $value string 165 | */ 166 | public function detail($value): self 167 | { 168 | $this->detail = $value; 169 | return $this; 170 | } 171 | 172 | /** 173 | * A human-readable string that represents a doc-comment. 174 | * Can contain Markdown 175 | * @param $value string 176 | */ 177 | public function documentation($value): self 178 | { 179 | $this->documentation = $value; 180 | return $this; 181 | } 182 | 183 | /** 184 | * A string that should be used when filtering a set of completion items. 185 | * When falsy the `label` is used. 186 | * @param $value string 187 | */ 188 | public function filterText($value): self 189 | { 190 | $this->filterText = $value; 191 | return $this; 192 | } 193 | 194 | /** 195 | * A string or snippet that should be inserted in a document when selecting this completion. 196 | * @param $value string 197 | */ 198 | public function insertText($value): self 199 | { 200 | $this->insertText = $value; 201 | return $this; 202 | } 203 | 204 | /** 205 | * Additional rules (as bitmask) that should be applied when inserting this completion. 206 | * @param $value int 207 | */ 208 | public function insertTextRules($value): self 209 | { 210 | $this->insertTextRules = $value; 211 | return $this; 212 | } 213 | 214 | /** 215 | * The kind of this completion item. Based on the kind an icon is chosen by the editor. 216 | * @param $value int 217 | */ 218 | public function kind($value): self 219 | { 220 | $this->kind = $value; 221 | return $this; 222 | } 223 | 224 | /** 225 | * The label of this completion item. By default this is also the text that is inserted 226 | * when selecting this completion. 227 | * @param $value string 228 | */ 229 | public function label($value): self 230 | { 231 | $this->label = $value; 232 | return $this; 233 | } 234 | 235 | /** 236 | * Select this item when showing. Note that only one completion item can be selected and that 237 | * the editor decides which item that is. The rule is that the first item of those that match best is selected. 238 | * @param $value bool 239 | */ 240 | public function preselect($value): self 241 | { 242 | $this->preselect = $value; 243 | return $this; 244 | } 245 | 246 | /** 247 | * A range of text that should be replaced by this completion item. 248 | * @param $value array 249 | */ 250 | public function range($value): self 251 | { 252 | $this->range = $value; 253 | return $this; 254 | } 255 | 256 | /** 257 | * A string that should be used when comparing this item with other items. When falsy 258 | * the `label` is used. 259 | * @param $value string 260 | */ 261 | public function sortText($value): self 262 | { 263 | $this->sortText = $value; 264 | return $this; 265 | } 266 | 267 | /** 268 | * A modifier to the kind which affect how the item is rendered, e.g. Deprecated is rendered 269 | * with a strikeout 270 | * @param $value array 271 | */ 272 | public function tags($value): self 273 | { 274 | $this->tags = $value; 275 | return $this; 276 | } 277 | 278 | /** 279 | * Add the completion item to the passed in AutocompleteInterface static class 280 | * 281 | * @param AutocompleteInterface $autocomplete 282 | * @param string $path The . delimited path in the autocomplete array to the item; if omitted, will be set to the $item->label 283 | * @return void 284 | */ 285 | public function add($autocomplete, string $path = ''): void 286 | { 287 | $autocomplete->addCompleteItem($this, $path); 288 | } 289 | 290 | /** 291 | * @inheritdoc 292 | */ 293 | public function defineRules(): array 294 | { 295 | return [ 296 | 297 | [ 298 | [ 299 | 'detail', 300 | 'documentation', 301 | 'filterText', 302 | 'insertText', 303 | 'label', 304 | 'sortText', 305 | 'tags', 306 | ], 307 | 'string' 308 | ], 309 | [ 310 | [ 311 | 'additionalTextEdits', 312 | 'command', 313 | 'commitCharacters', 314 | 'range', 315 | ], 316 | ArrayValidator::class 317 | ], 318 | ['insertTextRules', 'integer', 'min' => 0, 'max' => 4], 319 | ['kind', 'integer', 'min' => 0, 'max' => 27], 320 | ['preselect', 'boolean'], 321 | [ 322 | [ 323 | 'insertText', 324 | 'kind', 325 | ], 326 | 'required' 327 | ], 328 | ]; 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/web/assets/src/js/autocomplete.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Twigfield Craft CMS 3 | * 4 | * Provides a twig editor field with Twig & Craft API autocomplete 5 | * 6 | * @link https://nystudio107.com 7 | * @copyright Copyright (c) 2022 nystudio107 8 | */ 9 | 10 | /** 11 | * @author nystudio107 12 | * @package Twigfield 13 | * @since 1.0.0 14 | */ 15 | 16 | declare global { 17 | interface Window { 18 | monaco: string; 19 | monacoAutocompleteItems: {[key: string]: string}, 20 | twigfieldFieldTypes: {[key: string]: string}, 21 | } 22 | } 23 | 24 | import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; 25 | 26 | const COMPLETION_KEY = '__completions'; 27 | 28 | /** 29 | * Get the last item from the array 30 | * 31 | * @param {Array} arr 32 | * @returns {T} 33 | */ 34 | function getLastItem(arr: Array): T { 35 | return arr[arr.length - 1]; 36 | } 37 | 38 | /** 39 | * Register completion items with the Monaco editor, for the Twig language 40 | * 41 | * @param {AutocompleteItem} completionItems - completion items, with sub-properties in `COMPLETION_KEY` 42 | * @param {AutocompleteTypes} autocompleteType - the type of autocomplete 43 | * @param {boolean} hasSubProperties - whether the autocomplete has sub-properties, and should be parsed as such 44 | */ 45 | function addCompletionItemsToMonaco(completionItems: AutocompleteItem, autocompleteType: AutocompleteTypes, hasSubProperties: boolean): void { 46 | monaco.languages.registerCompletionItemProvider('twig', { 47 | triggerCharacters: ['.', '('], 48 | provideCompletionItems: function (model, position, token) { 49 | let result: monaco.languages.CompletionItem[] = []; 50 | let currentItems = completionItems; 51 | // Get the last word the user has typed 52 | const currentLine = model.getValueInRange({ 53 | startLineNumber: position.lineNumber, 54 | startColumn: 0, 55 | endLineNumber: position.lineNumber, 56 | endColumn: position.column 57 | }); 58 | let inTwigExpression = true; 59 | // Ensure we're inside of a Twig expression 60 | if (currentLine.lastIndexOf('{') === -1) { 61 | inTwigExpression = false; 62 | } 63 | const startExpression = currentLine.substring(currentLine.lastIndexOf('{')); 64 | if (startExpression.indexOf('}') !== -1) { 65 | inTwigExpression = false; 66 | } 67 | // We are not in a Twig expression, and this is a TwigExpressionAutocomplete, return nothing 68 | if (!inTwigExpression && autocompleteType === 'TwigExpressionAutocomplete') { 69 | return null; 70 | } 71 | // Get the current word we're typing 72 | const currentWords = currentLine.replace("\t", "").split(" "); 73 | let currentWord = currentWords[currentWords.length - 1]; 74 | // If the current word includes { or ( or >, split on that, too, to allow the autocomplete to work in nested functions and HTML tags 75 | if (currentWord.includes('{')) { 76 | currentWord = getLastItem(currentWord.split('{')); 77 | } 78 | if (currentWord.includes('(')) { 79 | currentWord = getLastItem(currentWord.split('(')); 80 | } 81 | if (currentWord.includes('>')) { 82 | currentWord = getLastItem(currentWord.split('>')); 83 | } 84 | const isSubProperty = currentWord.charAt(currentWord.length - 1) === "."; 85 | // If we're in a sub-property (following a .) don't present non-TwigExpressionAutocomplete items 86 | if (isSubProperty && autocompleteType !== 'TwigExpressionAutocomplete') { 87 | return null; 88 | } 89 | // We are in a Twig expression, handle TwigExpressionAutocomplete by walking through the properties 90 | if (inTwigExpression && autocompleteType === 'TwigExpressionAutocomplete') { 91 | // If the last character typed is a period, then we need to look up a sub-property of the completionItems 92 | if (isSubProperty) { 93 | // If we're in a sub-property, and this autocomplete doesn't have sub-properties, don't return its items 94 | if (!hasSubProperties) { 95 | return null; 96 | } 97 | // Is a sub-property, get a list of parent properties 98 | const parents = currentWord.substring(0, currentWord.length - 1).split("."); 99 | if (typeof completionItems[parents[0]] !== 'undefined') { 100 | currentItems = completionItems[parents[0]]; 101 | // Loop through all the parents to traverse the completion items and find the current one 102 | for (let i = 1; i < parents.length; i++) { 103 | if (currentItems.hasOwnProperty(parents[i])) { 104 | currentItems = currentItems[parents[i]]; 105 | } else { 106 | const finalItems: monaco.languages.ProviderResult = { 107 | suggestions: result 108 | } 109 | return finalItems; 110 | } 111 | } 112 | } 113 | } 114 | } 115 | // Get all the child properties 116 | if (typeof currentItems !== 'undefined') { 117 | for (let item in currentItems) { 118 | if (currentItems.hasOwnProperty(item) && !item.startsWith("__")) { 119 | const completionItem = currentItems[item][COMPLETION_KEY]; 120 | if (typeof completionItem !== 'undefined') { 121 | // Monaco adds a 'range' to the object, to denote where the autocomplete is triggered from, 122 | // which needs to be removed each time the autocomplete objects are re-used 123 | delete completionItem.range; 124 | if ('documentation' in completionItem && typeof completionItem.documentation !== 'object') { 125 | let docs = completionItem.documentation; 126 | completionItem.documentation = { 127 | value: docs, 128 | isTrusted: true, 129 | supportsHtml: true 130 | } 131 | } 132 | // Add to final results 133 | result.push(completionItem); 134 | } 135 | } 136 | } 137 | } 138 | 139 | const finalItems: monaco.languages.ProviderResult = { 140 | suggestions: result 141 | } 142 | return finalItems; 143 | } 144 | }); 145 | } 146 | 147 | /** 148 | * Register hover items with the Monaco editor, for the Twig language 149 | * 150 | * @param {AutocompleteItem} completionItems - completion items, with sub-properties in `COMPLETION_KEY` 151 | * @param {AutocompleteTypes} autocompleteType the type of autocomplete 152 | */ 153 | function addHoverHandlerToMonaco(completionItems: AutocompleteItem, autocompleteType: AutocompleteTypes): void { 154 | monaco.languages.registerHoverProvider('twig', { 155 | provideHover: function (model, position) { 156 | let result: monaco.languages.Hover; 157 | const currentLine = model.getValueInRange({ 158 | startLineNumber: position.lineNumber, 159 | startColumn: 0, 160 | endLineNumber: position.lineNumber, 161 | endColumn: model.getLineMaxColumn(position.lineNumber) 162 | }); 163 | const currentWord = model.getWordAtPosition(position); 164 | if (currentWord === null) { 165 | return; 166 | } 167 | let searchLine = currentLine.substring(0, currentWord.endColumn - 1) 168 | let isSubProperty = false; 169 | let currentItems = completionItems; 170 | for (let i = searchLine.length; i >= 0; i--) { 171 | if (searchLine[i] === ' ') { 172 | searchLine = currentLine.substring(i + 1, searchLine.length); 173 | break; 174 | } 175 | } 176 | if (searchLine.includes('.')) { 177 | isSubProperty = true; 178 | } 179 | if (isSubProperty) { 180 | // Is a sub-property, get a list of parent properties 181 | const parents = searchLine.substring(0, searchLine.length).split("."); 182 | // Loop through all the parents to traverse the completion items and find the current one 183 | for (let i = 0; i < parents.length - 1; i++) { 184 | const thisParent = parents[i].replace(/[{(<]/, ''); 185 | if (currentItems.hasOwnProperty(thisParent)) { 186 | currentItems = currentItems[thisParent]; 187 | } else { 188 | return; 189 | } 190 | } 191 | } 192 | if (typeof currentItems !== 'undefined' && typeof currentItems[currentWord.word] !== 'undefined') { 193 | const completionItem = currentItems[currentWord.word][COMPLETION_KEY]; 194 | if (typeof completionItem !== 'undefined') { 195 | let docs = completionItem.documentation; 196 | if (typeof completionItem.documentation === 'object') { 197 | docs = completionItem.documentation.value; 198 | } 199 | 200 | const finalHover: monaco.languages.ProviderResult = { 201 | range: new monaco.Range(position.lineNumber, currentWord.startColumn, position.lineNumber, currentWord.endColumn), 202 | contents: [ 203 | {value: '**' + completionItem.detail + '**'}, 204 | {value: docs}, 205 | ] 206 | } 207 | return finalHover 208 | } 209 | } 210 | 211 | return; 212 | } 213 | }); 214 | } 215 | 216 | /** 217 | * Fetch the autocompletion items frin the endpoint 218 | * 219 | * @param {string} fieldType - The field's passed in type, used for autocomplete caching 220 | * @param {string} codefieldOptions - JSON encoded string of arbitrary CodeEditorOptions for the field 221 | * @param {string} endpointUrl - The controller action endpoint for generating autocomplete items 222 | */ 223 | function getCompletionItemsFromEndpoint(fieldType: string = 'Twigfield', codefieldOptions: string = '', endpointUrl: string): void { 224 | const searchParams = new URLSearchParams(); 225 | if (typeof fieldType !== 'undefined') { 226 | searchParams.set('fieldType', fieldType); 227 | } 228 | if (typeof codefieldOptions !== 'undefined') { 229 | searchParams.set('twigfieldOptions', codefieldOptions); 230 | } 231 | const glueChar = endpointUrl.includes('?') ? '&' : '?'; 232 | // Only issue the XHR if we haven't loaded the autocompletes for this fieldType already 233 | if (typeof window.twigfieldFieldTypes === 'undefined') { 234 | window.twigfieldFieldTypes = {}; 235 | } 236 | if (fieldType in window.twigfieldFieldTypes) { 237 | return; 238 | } 239 | window.twigfieldFieldTypes[fieldType] = fieldType; 240 | // Ping the controller endpoint 241 | let request = new XMLHttpRequest(); 242 | request.open('GET', endpointUrl + glueChar + searchParams.toString(), true); 243 | request.onload = function () { 244 | if (request.status >= 200 && request.status < 400) { 245 | const completionItems: AutocompleteResponse = JSON.parse(request.responseText); 246 | if (typeof window.monacoAutocompleteItems === 'undefined') { 247 | window.monacoAutocompleteItems = {}; 248 | } 249 | // Don't add a completion more than once, as might happen with multiple Twigfield instances 250 | // on the same page, because the completions are global in Monaco 251 | for (const [name, autocomplete] of Object.entries(completionItems)) { 252 | if (!(autocomplete.name in window.monacoAutocompleteItems)) { 253 | window.monacoAutocompleteItems[autocomplete.name] = autocomplete.name; 254 | addCompletionItemsToMonaco(autocomplete.__completions, autocomplete.type, autocomplete.hasSubProperties); 255 | addHoverHandlerToMonaco(autocomplete.__completions, autocomplete.type); 256 | } 257 | } 258 | } else { 259 | console.log('Autocomplete endpoint failed with status ' + request.status) 260 | } 261 | }; 262 | request.send(); 263 | } 264 | 265 | export {getCompletionItemsFromEndpoint}; 266 | -------------------------------------------------------------------------------- /src/web/assets/dist/js/code-editor.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * @project code-editor 3 | * @name code-editor.js 4 | * @author Andrew Welch 5 | * @build Mon Oct 31 2022 14:52:05 GMT+0000 (Coordinated Universal Time) 6 | * @copyright Copyright (c) 2022 ©2022 nystudio107.com 7 | * 8 | */ 9 | "use strict";(self.webpackChunkBuildchain=self.webpackChunkBuildchain||[]).push([[85],{5919:function(){},3422:function(e,t,n){var o=n(713);function i(e){return e[e.length-1]}function s(e,t,n){o.languages.registerCompletionItemProvider("twig",{triggerCharacters:[".","("],provideCompletionItems:function(o,s,l){let c=[],r=e;const d=o.getValueInRange({startLineNumber:s.lineNumber,startColumn:0,endLineNumber:s.lineNumber,endColumn:s.column});let a=!0;-1===d.lastIndexOf("{")&&(a=!1);if(-1!==d.substring(d.lastIndexOf("{")).indexOf("}")&&(a=!1),!a&&"TwigExpressionAutocomplete"===t)return null;const u=d.replace("\t","").split(" ");let p=u[u.length-1];p.includes("{")&&(p=i(p.split("{"))),p.includes("(")&&(p=i(p.split("("))),p.includes(">")&&(p=i(p.split(">")));const m="."===p.charAt(p.length-1);if(m&&"TwigExpressionAutocomplete"!==t)return null;if(a&&"TwigExpressionAutocomplete"===t&&m){if(!n)return null;const t=p.substring(0,p.length-1).split(".");if(void 0!==e[t[0]]){r=e[t[0]];for(let e=1;e=0;e--)if(" "===l[e]){l=i.substring(e+1,l.length);break}if(l.includes(".")&&(c=!0),c){const e=l.substring(0,l.length).split(".");for(let t=0;t\n\n\n\t\n\t\n\n',javascript:'\n \n \n \n \n \n',markdown:'\n \n \n \n ',typescript:'\n \n \n \n ',css:'\n \n \n\n\n\n\n\n'},d={language:"twig",theme:"vs",automaticLayout:!0,tabIndex:0,lineNumbers:"off",glyphMargin:!1,folding:!1,lineDecorationsWidth:0,lineNumbersMinChars:0,renderLineHighlight:"none",wordWrap:"on",scrollBeyondLastLine:!1,scrollbar:{vertical:"hidden",horizontal:"auto",alwaysConsumeMouseWheel:!1,handleMouseWheel:!1},fontSize:14,fontFamily:'SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace',minimap:{enabled:!1}};function a(e,t,n){const i=n+"-monaco-editor",s=n+"-monaco-language-icon",l=document.querySelector("#"+i);if(null!==l&&void 0!==t){const n=r[t]??"",i=c[t]??"",d=document.createElement("div");o.editor.setModelLanguage(e.getModel(),t),d.id=s,""!==n&&(d.classList.add("monaco-editor-codefield--icon"),d.setAttribute("title",Craft.t("twigfield",i)),d.setAttribute("aria-hidden","true"),d.innerHTML=n);const a=l.querySelector("#"+s);a?l.replaceChild(d,a):l.appendChild(d)}}function u(e,t){const n=t??"vs";e.updateOptions({theme:n})}""===n.p&&(n.p=window.codeEditorBaseAssetsUrl),window.makeMonacoEditor=function(e,t,n,i,c,r,p=""){const m=document.getElementById(e),g=document.createElement("div"),f=JSON.parse(c),h=e+"-monaco-editor-placeholder";if(null===m||null===m.parentNode)return;const w=JSON.parse(i),v={...d,...w,value:m.value};if(g.id=e+"-monaco-editor",g.classList.add("monaco-editor","relative","box-content","monaco-editor-codefield","h-full"),""!==n){const e=g.classList,t=n.trim().split(/\s+/);e.add(...t)}if(""!==p){const t=document.createElement("div");t.id=e+"-monaco-editor-placeholder",t.innerHTML=p,t.classList.add("monaco-placeholder","p-2"),g.appendChild(t)}m.parentNode.insertBefore(g,m),m.style.display="none";const C=o.editor.create(g,v);if(void 0===window.monacoEditorInstances&&(window.monacoEditorInstances={}),window.monacoEditorInstances[e]=C,C.onDidChangeModelContent((()=>{m.value=C.getValue()})),a(C,v.language,e),u(C,v.theme),"singleLineEditor"in f&&f.singleLineEditor){const e=C.getModel();if(null!==e){const t=e.getValue();e.setValue(t.replace(/\s\s+/g," ")),C.addCommand(o.KeyMod.CtrlCmd|o.KeyCode.KeyF,(()=>{})),C.addCommand(o.KeyCode.Enter,(()=>{}),"!suggestWidgetVisible"),C.addCommand(o.KeyCode.Tab,(()=>{!function(){const e=x();if(document.activeElement instanceof HTMLFormElement){const t=e.indexOf(document.activeElement);if(t>-1){(e[t+1]||e[0]).focus()}}}()})),C.addCommand(o.KeyMod.Shift|o.KeyCode.Tab,(()=>{!function(){const e=x();if(document.activeElement instanceof HTMLFormElement){const t=e.indexOf(document.activeElement);if(t>-1){(e[t-1]||e[e.length]).focus()}}}()})),C.onDidPaste((()=>{let t="";const n=e.getLineCount();for(let o=0;o=200&&c.status<400){const e=JSON.parse(c.responseText);void 0===window.monacoAutocompleteItems&&(window.monacoAutocompleteItems={});for(const[t,n]of Object.entries(e))n.name in window.monacoAutocompleteItems||(window.monacoAutocompleteItems[n.name]=n.name,s(n.__completions,n.type,n.hasSubProperties),l(n.__completions,n.type))}else console.log("Autocomplete endpoint failed with status "+c.status)},c.send()}(t,c,r);let y=!1;const L=()=>{const e=C.getLayoutInfo().width,t=Math.min(1e3,C.getContentHeight());g.style.height=`${t}px`;try{y=!0,C.layout({width:e,height:t})}finally{y=!1}};function x(){let e=[];if(document.activeElement instanceof HTMLFormElement){const t=document.activeElement;t&&t.form&&(e=Array.prototype.filter.call(t.form.querySelectorAll('a:not([disabled]), button:not([disabled]), select:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])'),(function(e){return e instanceof HTMLElement&&(e.offsetWidth>0||e.offsetHeight>0||e===document.activeElement)})))}return e}function b(e,t){if(""===t){const t=document.querySelector(e);null!==t&&(t.style.display="initial")}}return C.onDidContentSizeChange(L),L(),""!==p&&(b("#"+h,C.getValue()),C.onDidBlurEditorWidget((()=>{b("#"+h,C.getValue())})),C.onDidFocusEditorWidget((()=>{!function(e){const t=document.querySelector(e);null!==t&&(t.style.display="none")}("#"+h)}))),C},window.setMonacoEditorLanguage=a,window.setMonacoEditorTheme=u}},function(e){var t=function(t){return e(e.s=t)};e.O(0,[216,532],(function(){return t(3422),t(5919),t(1828)}));e.O()}]); 10 | //# sourceMappingURL=code-editor.js.map -------------------------------------------------------------------------------- /src/web/assets/src/js/code-editor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Twigfield Craft CMS 3 | * 4 | * Provides a twig editor field with Twig & Craft API autocomplete 5 | * 6 | * @link https://nystudio107.com 7 | * @copyright Copyright (c) 2022 nystudio107 8 | */ 9 | 10 | /** 11 | * @author nystudio107 12 | * @package Twigfield 13 | * @since 1.0.0 14 | */ 15 | 16 | declare global { 17 | let __webpack_public_path__: string; 18 | const Craft: Craft; 19 | 20 | interface Window { 21 | codeEditorBaseAssetsUrl: string; 22 | makeMonacoEditor: MakeMonacoEditorFn; 23 | setMonacoEditorLanguage: SetMonacoEditorLanguageFn; 24 | setMonacoEditorTheme: SetMonacoEditorThemeFn; 25 | monacoEditorInstances: {[key: string]: monaco.editor.IStandaloneCodeEditor}; 26 | } 27 | } 28 | 29 | // Set the __webpack_public_path__ dynamically so we can work inside of cpresources's hashed dir name 30 | // https://stackoverflow.com/questions/39879680/example-of-setting-webpack-public-path-at-runtime 31 | if (typeof __webpack_public_path__ === 'undefined' || __webpack_public_path__ === '') { 32 | __webpack_public_path__ = window.codeEditorBaseAssetsUrl; 33 | } 34 | 35 | import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; 36 | import {getCompletionItemsFromEndpoint} from './autocomplete'; 37 | import {languageIcons, languageIconTitles} from './language-icons' 38 | import {defaultMonacoEditorOptions} from './default-monaco-editor-options' 39 | 40 | /** 41 | * Create a Monaco Editor instance 42 | * 43 | * @param {string} elementId - The id of the TextArea or Input element to replace with a Monaco editor 44 | * @param {string} fieldType - The field's passed in type, used for autocomplete caching 45 | * @param {string} wrapperClass - Classes that should be added to the field's wrapper
46 | * @param {IStandaloneEditorConstructionOptions} editorOptions - Monaco editor options 47 | * @param {string} codefieldOptions - JSON encoded string of arbitrary CodeEditorOptions for the field 48 | * @param {string} endpointUrl - The controller action endpoint for generating autocomplete items 49 | * @param {string} placeholderText - Placeholder text to use for the field 50 | */ 51 | function makeMonacoEditor(elementId: string, fieldType: string, wrapperClass: string, editorOptions: string, codefieldOptions: string, endpointUrl: string, placeholderText = ''): monaco.editor.IStandaloneCodeEditor | undefined { 52 | const textArea = document.getElementById(elementId); 53 | const container = document.createElement('div'); 54 | const fieldOptions: CodeEditorOptions = JSON.parse(codefieldOptions); 55 | const placeholderId = elementId + '-monaco-editor-placeholder'; 56 | // If we can't find the passed in text area or if there is no parent node, return 57 | if (textArea === null || textArea.parentNode === null) { 58 | return; 59 | } 60 | // Monaco editor defaults, coalesced together 61 | const monacoEditorOptions: monaco.editor.IStandaloneEditorConstructionOptions = JSON.parse(editorOptions); 62 | const options: monaco.editor.IStandaloneEditorConstructionOptions = {...defaultMonacoEditorOptions, ...monacoEditorOptions, ...{value: textArea.value}} 63 | // Make a sibling div for the Monaco editor to live in 64 | container.id = elementId + '-monaco-editor'; 65 | container.classList.add('monaco-editor','relative', 'box-content', 'monaco-editor-codefield', 'h-full'); 66 | // Apply any passed in classes to the wrapper div 67 | if (wrapperClass !== '') { 68 | const cl = container.classList; 69 | const classArray = wrapperClass.trim().split(/\s+/); 70 | cl.add(...classArray); 71 | } 72 | // Handle the placeholder text (if any) 73 | if (placeholderText !== '') { 74 | const placeholder = document.createElement('div'); 75 | placeholder.id = elementId + '-monaco-editor-placeholder'; 76 | placeholder.innerHTML = placeholderText; 77 | placeholder.classList.add('monaco-placeholder', 'p-2'); 78 | container.appendChild(placeholder); 79 | } 80 | textArea.parentNode.insertBefore(container, textArea); 81 | textArea.style.display = 'none'; 82 | // Create the Monaco editor 83 | const editor = monaco.editor.create(container, options); 84 | // Make the monaco editor instances available via the monacoEditorInstances global, since Twig macros can't return a value 85 | if (typeof window.monacoEditorInstances === 'undefined') { 86 | window.monacoEditorInstances = {}; 87 | } 88 | window.monacoEditorInstances[elementId] = editor; 89 | // When the text is changed in the editor, sync it to the underlying TextArea input 90 | editor.onDidChangeModelContent(() => { 91 | textArea.value = editor.getValue(); 92 | }); 93 | // Add the language icon (if any) 94 | setMonacoEditorLanguage(editor, options.language, elementId); 95 | // Set the editor theme 96 | setMonacoEditorTheme(editor, options.theme); 97 | // ref: https://github.com/vikyd/vue-monaco-singleline/blob/master/src/monaco-singleline.vue#L150 98 | if ('singleLineEditor' in fieldOptions && fieldOptions.singleLineEditor) { 99 | const textModel: monaco.editor.ITextModel | null = editor.getModel(); 100 | if (textModel !== null) { 101 | // Remove multiple spaces & tabs 102 | const text = textModel.getValue(); 103 | textModel.setValue(text.replace(/\s\s+/g, ' ')); 104 | // Handle the Find command 105 | editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => { 106 | }); 107 | // Handle typing the Enter key 108 | editor.addCommand(monaco.KeyCode.Enter, () => { 109 | }, '!suggestWidgetVisible'); 110 | // Handle typing the Tab key 111 | editor.addCommand(monaco.KeyCode.Tab, () => { 112 | focusNextElement(); 113 | }); 114 | editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Tab, () => { 115 | focusPrevElement(); 116 | }); 117 | // Handle Paste 118 | editor.onDidPaste(() => { 119 | // multiple rows will be merged to single row 120 | let newContent = ''; 121 | const lineCount = textModel.getLineCount(); 122 | // remove all line breaks 123 | for (let i = 0; i < lineCount; i += 1) { 124 | newContent += textModel.getLineContent(i + 1); 125 | } 126 | // Remove multiple spaces & tabs 127 | newContent = newContent.replace(/\s\s+/g, ' '); 128 | textModel.setValue(newContent); 129 | editor.setPosition({column: newContent.length + 1, lineNumber: 1}); 130 | }) 131 | } 132 | } 133 | // Get the autocompletion items 134 | getCompletionItemsFromEndpoint(fieldType, codefieldOptions, endpointUrl); 135 | // Custom resizer to always keep the editor full-height, without needing to scroll 136 | let ignoreEvent = false; 137 | const updateHeight = () => { 138 | const width = editor.getLayoutInfo().width; 139 | const contentHeight = Math.min(1000, editor.getContentHeight()); 140 | //container.style.width = `${width}px`; 141 | container.style.height = `${contentHeight}px`; 142 | try { 143 | ignoreEvent = true; 144 | editor.layout({width, height: contentHeight}); 145 | } finally { 146 | ignoreEvent = false; 147 | } 148 | }; 149 | editor.onDidContentSizeChange(updateHeight); 150 | updateHeight(); 151 | // Handle the placeholder 152 | if (placeholderText !== '') { 153 | showPlaceholder('#' + placeholderId, editor.getValue()); 154 | editor.onDidBlurEditorWidget(() => { 155 | showPlaceholder('#' + placeholderId, editor.getValue()); 156 | }); 157 | editor.onDidFocusEditorWidget(() => { 158 | hidePlaceholder('#' + placeholderId); 159 | }); 160 | } 161 | 162 | /** 163 | * Move the focus to the next element 164 | */ 165 | function focusNextElement(): void { 166 | const focusable = getFocusableElements(); 167 | if (document.activeElement instanceof HTMLFormElement) { 168 | const index = focusable.indexOf(document.activeElement); 169 | if (index > -1) { 170 | const nextElement = focusable[index + 1] || focusable[0]; 171 | nextElement.focus(); 172 | } 173 | } 174 | } 175 | 176 | /** 177 | * Move the focus to the previous element 178 | */ 179 | function focusPrevElement(): void { 180 | const focusable = getFocusableElements(); 181 | if (document.activeElement instanceof HTMLFormElement) { 182 | const index = focusable.indexOf(document.activeElement); 183 | if (index > -1) { 184 | const prevElement = focusable[index - 1] || focusable[focusable.length]; 185 | prevElement.focus(); 186 | } 187 | } 188 | } 189 | 190 | /** 191 | * Get the focusable elements in the current form 192 | * 193 | * @returns {Array} - An array of HTMLElements that can be focusable 194 | */ 195 | function getFocusableElements(): Array { 196 | let focusable: Array = []; 197 | // add all elements we want to include in our selection 198 | const focusableElements = 'a:not([disabled]), button:not([disabled]), select:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])'; 199 | if (document.activeElement instanceof HTMLFormElement) { 200 | const activeElement: HTMLFormElement = document.activeElement; 201 | if (activeElement && activeElement.form) { 202 | focusable = Array.prototype.filter.call(activeElement.form.querySelectorAll(focusableElements), 203 | function (element) { 204 | if (element instanceof HTMLElement) { 205 | //check for visibility while always include the current activeElement 206 | return element.offsetWidth > 0 || element.offsetHeight > 0 || element === document.activeElement 207 | } 208 | return false; 209 | }); 210 | } 211 | } 212 | 213 | return focusable; 214 | } 215 | 216 | /** 217 | * Show the placeholder text 218 | * 219 | * @param {string} selector - The selector for the placeholder element 220 | * @param {string} value - The editor field's value (the text) 221 | */ 222 | function showPlaceholder(selector: string, value: string): void { 223 | if (value === "") { 224 | const elem = document.querySelector(selector); 225 | if (elem !== null) { 226 | elem.style.display = "initial"; 227 | } 228 | } 229 | } 230 | 231 | /** 232 | * Hide the placeholder text 233 | * 234 | * @param {string} selector - The selector for the placeholder element 235 | */ 236 | function hidePlaceholder(selector: string): void { 237 | const elem = document.querySelector(selector); 238 | if (elem !== null) { 239 | elem.style.display = "none"; 240 | } 241 | } 242 | 243 | return editor; 244 | } 245 | 246 | /** 247 | * Set the language for the Monaco editor instance 248 | * 249 | * @param {monaco.editor.IStandaloneCodeEditor} editor - the Monaco editor instance 250 | * @param {string | undefined} language - the editor language 251 | * @param {string} elementId - the element id used to create the monaco editor from 252 | */ 253 | function setMonacoEditorLanguage(editor: monaco.editor.IStandaloneCodeEditor, language: string | undefined, elementId: string): void { 254 | const containerId = elementId + '-monaco-editor'; 255 | const iconId = elementId + '-monaco-language-icon'; 256 | const container = document.querySelector('#' + containerId); 257 | if (container !== null) { 258 | if (typeof language !== "undefined") { 259 | const languageIcon = languageIcons[language] ?? ''; 260 | const languageTitle = languageIconTitles[language] ?? ''; 261 | const icon = document.createElement('div'); 262 | monaco.editor.setModelLanguage(editor.getModel()!, language); 263 | icon.id = iconId; 264 | // Only add in the icon if one is available 265 | if (languageIcon !== '') { 266 | icon.classList.add('monaco-editor-codefield--icon'); 267 | icon.setAttribute('title', Craft.t('twigfield', languageTitle)); 268 | icon.setAttribute('aria-hidden', 'true'); 269 | icon.innerHTML = languageIcon; 270 | } 271 | // Replace the icon if it exists, otherwise create a new element 272 | const currentIcon = container.querySelector('#' + iconId); 273 | if (currentIcon) { 274 | container.replaceChild(icon, currentIcon); 275 | } else { 276 | container.appendChild(icon); 277 | } 278 | } 279 | } 280 | } 281 | 282 | /** 283 | * Set the theme for the Monaco editor instance 284 | * 285 | * @param {monaco.editor.IStandaloneCodeEditor} editor - the Monaco editor instance 286 | * @param {string | undefined} language - the editor theme 287 | */ 288 | function setMonacoEditorTheme(editor: monaco.editor.IStandaloneCodeEditor, theme: string | undefined): void { 289 | const editorTheme = theme ?? 'vs'; 290 | editor.updateOptions({theme: editorTheme}); 291 | } 292 | 293 | // Make the functions globally available 294 | window.makeMonacoEditor = makeMonacoEditor; 295 | window.setMonacoEditorLanguage = setMonacoEditorLanguage; 296 | window.setMonacoEditorTheme = setMonacoEditorTheme; 297 | 298 | export {makeMonacoEditor, setMonacoEditorLanguage, setMonacoEditorTheme}; 299 | 300 | -------------------------------------------------------------------------------- /src/base/ObjectParserAutocomplete.php: -------------------------------------------------------------------------------- 1 | self::RECURSION_DEPTH_LIMIT) { 108 | return; 109 | } 110 | $recursionDepth++; 111 | // Create the docblock factory 112 | $factory = DocBlockFactory::createInstance(); 113 | 114 | $path = trim(implode('.', [$path, $name]), '.'); 115 | // The class itself 116 | if ($this->parseClass) { 117 | $this->getClassCompletion($object, $factory, $name, $path); 118 | } 119 | // ServiceLocator Components 120 | if ($this->parseComponents) { 121 | $this->getComponentCompletion($object, $recursionDepth, $path); 122 | } 123 | // Class properties 124 | if ($this->parseProperties) { 125 | $this->getPropertyCompletion($object, $factory, $recursionDepth, $path); 126 | } 127 | // Class methods 128 | if ($this->parseMethods) { 129 | $this->getMethodCompletion($object, $factory, $path); 130 | } 131 | // Behavior properties 132 | if ($this->parseBehaviors) { 133 | $this->getBehaviorCompletion($object, $factory, $recursionDepth, $path); 134 | } 135 | } 136 | 137 | // Protected Methods 138 | // ========================================================================= 139 | 140 | /** 141 | * @param $object 142 | * @param DocBlockFactory $factory 143 | * @param string $name 144 | * @param $path 145 | */ 146 | protected function getClassCompletion($object, DocBlockFactory $factory, string $name, $path): void 147 | { 148 | try { 149 | $reflectionClass = new ReflectionClass($object); 150 | } catch (ReflectionException $e) { 151 | return; 152 | } 153 | // Information on the class itself 154 | $className = $reflectionClass->getName(); 155 | $docs = $this->getDocs($reflectionClass, $factory); 156 | CompleteItem::create() 157 | ->detail((string)$className) 158 | ->documentation((string)$docs) 159 | ->kind(CompleteItemKind::ClassKind) 160 | ->label((string)$name) 161 | ->insertText((string)$name) 162 | ->add($this, $path); 163 | } 164 | 165 | /** 166 | * @param $object 167 | * @param $recursionDepth 168 | * @param $path 169 | */ 170 | protected function getComponentCompletion($object, $recursionDepth, $path): void 171 | { 172 | if ($object instanceof ServiceLocator) { 173 | foreach ($object->getComponents() as $key => $value) { 174 | $componentObject = null; 175 | try { 176 | $componentObject = $object->get($key); 177 | } catch (InvalidConfigException $e) { 178 | // That's okay 179 | } 180 | if ($componentObject) { 181 | $this->parseObject($key, $componentObject, $recursionDepth, $path); 182 | } 183 | } 184 | } 185 | } 186 | 187 | /** 188 | * @param $object 189 | * @param DocBlockFactory $factory 190 | * @param $recursionDepth 191 | * @param string $path 192 | */ 193 | protected function getPropertyCompletion($object, DocBlockFactory $factory, $recursionDepth, string $path): void 194 | { 195 | try { 196 | $reflectionClass = new ReflectionClass($object); 197 | } catch (ReflectionException $e) { 198 | return; 199 | } 200 | $reflectionProperties = $reflectionClass->getProperties(); 201 | $customField = false; 202 | if ($object instanceof Behavior) { 203 | $customField = true; 204 | } 205 | $sortPrefix = $customField ? $this->customPropertySortPrefix : $this->propertySortPrefix; 206 | foreach ($reflectionProperties as $reflectionProperty) { 207 | $propertyName = $reflectionProperty->getName(); 208 | // Exclude some properties 209 | $propertyAllowed = true; 210 | foreach (self::EXCLUDED_PROPERTY_REGEXES as $excludePattern) { 211 | $pattern = '`' . $excludePattern . '`i'; 212 | if (preg_match($pattern, $propertyName) === 1) { 213 | $propertyAllowed = false; 214 | } 215 | } 216 | if (in_array($propertyName, self::EXCLUDED_PROPERTY_NAMES, true)) { 217 | $propertyAllowed = false; 218 | } 219 | if ($customField && in_array($propertyName, self::EXCLUDED_BEHAVIOR_NAMES, true)) { 220 | $propertyAllowed = false; 221 | } 222 | // Process the property 223 | if ($propertyAllowed && $reflectionProperty->isPublic()) { 224 | $detail = "Property"; 225 | $docblock = null; 226 | $docs = $reflectionProperty->getDocComment(); 227 | if ($docs) { 228 | $docblock = $factory->create($docs); 229 | $docs = ''; 230 | $summary = $docblock->getSummary(); 231 | if (!empty($summary)) { 232 | $docs = $summary; 233 | } 234 | $description = $docblock->getDescription()->render(); 235 | if (!empty($description)) { 236 | $docs = $description; 237 | } 238 | } 239 | // Figure out the type 240 | if ($docblock) { 241 | $tag = $docblock->getTagsByName('var'); 242 | if ($tag && isset($tag[0])) { 243 | $docs = $tag[0]; 244 | } 245 | } 246 | if (preg_match('/@var\s+([^\s]+)/', $docs, $matches)) { 247 | list(, $type) = $matches; 248 | $detail = $type; 249 | } 250 | if ($detail === "Property") { 251 | if ((PHP_MAJOR_VERSION >= 7 && PHP_MINOR_VERSION >= 4) || (PHP_MAJOR_VERSION >= 8)) { 252 | if ($reflectionProperty->hasType()) { 253 | $reflectionType = $reflectionProperty->getType(); 254 | if ($reflectionType instanceof ReflectionNamedType) { 255 | $type = $reflectionType->getName(); 256 | $detail = $type; 257 | } 258 | } 259 | if ((PHP_MAJOR_VERSION >= 8) && $reflectionProperty->hasDefaultValue()) { 260 | $value = $reflectionProperty->getDefaultValue(); 261 | if (is_array($value)) { 262 | $value = json_encode($value); 263 | } 264 | if (!empty($value)) { 265 | $detail = (string)$value; 266 | } 267 | } 268 | } 269 | } 270 | $thisPath = trim(implode('.', [$path, $propertyName]), '.'); 271 | $label = $propertyName; 272 | CompleteItem::create() 273 | ->detail((string)$detail) 274 | ->documentation((string)$docs) 275 | ->kind($customField ? CompleteItemKind::FieldKind : CompleteItemKind::PropertyKind) 276 | ->label((string)$label) 277 | ->insertText((string)$label) 278 | ->sortText((string)$sortPrefix . (string)$label) 279 | ->add($this, $thisPath); 280 | // Recurse through if this is an object 281 | if (isset($object->$propertyName) && is_object($object->$propertyName)) { 282 | if (!$customField && !in_array($propertyName, self::EXCLUDED_PROPERTY_NAMES, true)) { 283 | $this->parseObject($propertyName, $object->$propertyName, $recursionDepth, $path); 284 | } 285 | } 286 | } 287 | } 288 | } 289 | 290 | /** 291 | * @param $object 292 | * @param DocBlockFactory $factory 293 | * @param string $path 294 | */ 295 | protected function getMethodCompletion($object, DocBlockFactory $factory, string $path): void 296 | { 297 | try { 298 | $reflectionClass = new ReflectionClass($object); 299 | } catch (ReflectionException $e) { 300 | return; 301 | } 302 | $reflectionMethods = $reflectionClass->getMethods(); 303 | foreach ($reflectionMethods as $reflectionMethod) { 304 | $methodName = $reflectionMethod->getName(); 305 | // Exclude some properties 306 | $methodAllowed = true; 307 | foreach (self::EXCLUDED_METHOD_REGEXES as $excludePattern) { 308 | $pattern = '`' . $excludePattern . '`i'; 309 | if (preg_match($pattern, $methodName) === 1) { 310 | $methodAllowed = false; 311 | } 312 | } 313 | // Process the method 314 | if ($methodAllowed && $reflectionMethod->isPublic()) { 315 | $docblock = null; 316 | $docs = $this->getDocs($reflectionMethod, $factory); 317 | $detail = $methodName . '('; 318 | $params = $reflectionMethod->getParameters(); 319 | $paramList = []; 320 | foreach ($params as $param) { 321 | if ($param->hasType()) { 322 | $reflectionType = $param->getType(); 323 | if ($reflectionType instanceof ReflectionUnionType) { 324 | $unionTypes = $reflectionType->getTypes(); 325 | $typeName = ''; 326 | foreach ($unionTypes as $unionType) { 327 | $typeName .= '|' . $unionType->getName(); 328 | } 329 | $typeName = trim($typeName, '|'); 330 | $paramList[] = $typeName . ': ' . '$' . $param->getName(); 331 | } else { 332 | $paramList[] = $param->getType()->getName() . ': ' . '$' . $param->getName(); 333 | } 334 | } else { 335 | $paramList[] = '$' . $param->getName(); 336 | } 337 | } 338 | $detail .= implode(', ', $paramList) . ')'; 339 | $thisPath = trim(implode('.', [$path, $methodName]), '.'); 340 | $label = $methodName . '()'; 341 | $docsPreamble = ''; 342 | // Figure out the type 343 | if ($docblock) { 344 | $tags = $docblock->getTagsByName('param'); 345 | if ($tags) { 346 | $docsPreamble = "Parameters:\n\n"; 347 | foreach ($tags as $tag) { 348 | $docsPreamble .= $tag . "\n"; 349 | } 350 | $docsPreamble .= "\n"; 351 | } 352 | } 353 | CompleteItem::create() 354 | ->detail((string)$detail) 355 | ->documentation((string)$docsPreamble . (string)$docs) 356 | ->kind(CompleteItemKind::MethodKind) 357 | ->label((string)$label) 358 | ->insertText((string)$label) 359 | ->sortText($this->methodSortPrefix . (string)$label) 360 | ->add($this, $thisPath); 361 | } 362 | } 363 | } 364 | 365 | /** 366 | * @param $object 367 | * @param DocBlockFactory $factory 368 | * @param $recursionDepth 369 | * @param string $path 370 | */ 371 | protected function getBehaviorCompletion($object, DocBlockFactory $factory, $recursionDepth, string $path): void 372 | { 373 | if ($object instanceof Element) { 374 | $behaviorClass = $object->getBehavior('customFields'); 375 | if ($behaviorClass) { 376 | $this->getPropertyCompletion($behaviorClass, $factory, $recursionDepth, $path); 377 | } 378 | } 379 | } 380 | 381 | /** 382 | * Try to get the best documentation block we can 383 | * 384 | * @param ReflectionClass|ReflectionMethod $reflection 385 | * @param DocBlockFactory $factory 386 | * @return string 387 | */ 388 | protected function getDocs($reflection, DocBlockFactory $factory): string 389 | { 390 | $docs = $reflection->getDocComment(); 391 | if ($docs) { 392 | $docblock = $factory->create($docs); 393 | $summary = $docblock->getSummary(); 394 | if (!empty($summary)) { 395 | $docs = $summary; 396 | } 397 | $description = $docblock->getDescription()->render(); 398 | if (!empty($description)) { 399 | $docs = $description; 400 | } 401 | } 402 | 403 | return $docs ?: ''; 404 | } 405 | } 406 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/nystudio107/craft-twigfield/badges/quality-score.png?b=v1)](https://scrutinizer-ci.com/g/nystudio107/craft-twigfield/?branch=develop) [![Code Coverage](https://scrutinizer-ci.com/g/nystudio107/craft-twigfield/badges/coverage.png?b=v1)](https://scrutinizer-ci.com/g/nystudio107/craft-twigfield/?branch=develop) [![Build Status](https://scrutinizer-ci.com/g/nystudio107/craft-twigfield/badges/build.png?b=v1)](https://scrutinizer-ci.com/g/nystudio107/craft-twigfield/build-status/develop) [![Code Intelligence Status](https://scrutinizer-ci.com/g/nystudio107/craft-twigfield/badges/code-intelligence.svg?b=v1)](https://scrutinizer-ci.com/code-intelligence) 2 | 3 | # DEPRECATED 4 | 5 | Twigfield is now deprecated; please use [nystudio107/craft-code-editor](https://github.com/nystudio107/craft-code-editor) instead, which is a general purpose code editor that also does Twig & autocompletes. 6 | 7 | # Twigfield for Craft CMS 3.x & 4.x 8 | 9 | Provides a twig editor field with Twig & Craft API autocomplete 10 | 11 | ![Demo](./resources/twigfield-demo.gif) 12 | 13 | ## Requirements 14 | 15 | Twigfield requires Craft CMS 3.0 or 4.0. 16 | 17 | ## Installation 18 | 19 | To install Twigfield, follow these steps: 20 | 21 | 1. Open your terminal and go to your Craft project: 22 | 23 | cd /path/to/project 24 | 25 | 2. Then tell Composer to require the package: 26 | 27 | composer require nystudio107/craft-twigfield 28 | 29 | ## About Twigfield 30 | 31 | Twigfield provides a full-featured Twig editor with syntax highlighting via the powerful [Monaco Editor](https://microsoft.github.io/monaco-editor/) (the same editor that is the basis for VS Code). 32 | 33 | Twigfield provides full autocompletion for [Twig](https://twig.symfony.com/doc/3.x/) filters/functions/tags, and the full [Craft CMS](https://craftcms.com/docs/4.x/) API, including installed plugins: 34 | 35 | ![Autocomplete](./resources/twigfield-autocomplete.png) 36 | 37 | And it adds hover documentation when you hover the cursor over an expression: 38 | 39 | ![Hovers](./resources/twigfield-hovers.png) 40 | 41 | You can also add your own custom Autocompletes, and customize the behavior of the editor. 42 | 43 | Twigfield also provides a [Yii2 Validator](https://www.yiiframework.com/doc/guide/2.0/en/tutorial-core-validators) for Twig templates and object templates. 44 | 45 | ## Using Twigfield 46 | 47 | Once you've added the `nystudio107/craft-twigfield` package to your plugin, module, or project, no further setup is needed. This is because it operates as an auto-bootstrapping Yii2 Module. 48 | 49 | Twigfield is not a Craft CMS plugin, rather a package to be utilized by a plugin, module, or project. 50 | 51 | It can be very easy to add to an existing project, as you can see from the [Preparse field pull request](https://github.com/besteadfast/craft-preparse-field/pull/81/files) that adds it the [Preparse plugin](https://github.com/besteadfast/craft-preparse-field). 52 | 53 | ### In the Craft CP 54 | 55 | Twigfield works just like the Craft CMS `forms` macros that should be familiar to plugin and module developers. 56 | 57 | #### Import Macros 58 | 59 | Simply import the macros: 60 | 61 | ```twig 62 | {% import "twigfield/twigfield" as twigfield %} 63 | ``` 64 | 65 | #### Multi-line Editor 66 | 67 | Then to create a `textarea` multi-line editor, do the following: 68 | 69 | ```twig 70 | {{ twigfield.textarea({ 71 | id: 'myTwigfield', 72 | name: 'myTwigfield', 73 | value: textAreaText, 74 | }) }} 75 | ``` 76 | 77 | ...where `textAreaText` is a variable containing the initial text that should be in the editor field. This will create the Twig editor. 78 | 79 | To create a `textareaField` multi-line editor, do the following: 80 | 81 | ```twig 82 | {{ twigfield.textareaField({ 83 | label: "Twig Editor"|t, 84 | instructions: "Enter any Twig code below, with full API autocompletion."|t, 85 | id: 'myTwigfield', 86 | name: 'myTwigfield', 87 | value: textAreaText, 88 | }) }} 89 | ``` 90 | 91 | ...where `textAreaText` is a variable containing the initial text that should be in the editor field. This will create the `label` and `instructions`, along with the Twig editor. 92 | 93 | #### Single-line Editor 94 | 95 | Then to create a `text` single-line editor, do the following: 96 | 97 | ```twig 98 | {{ twigfield.text({ 99 | id: 'myTwigfield', 100 | name: 'myTwigfield', 101 | value: text, 102 | }) }} 103 | ``` 104 | 105 | ...where `text` is a variable containing the initial text that should be in the editor field. This will create the Twig editor that is restricted to a single line, for simple Twig expressions. 106 | 107 | To create a `textField` single-line editor, do the following: 108 | 109 | ```twig 110 | {{ twigfield.textField({ 111 | label: "Twig Editor"|t, 112 | instructions: "Enter any Twig code below, with full API autocompletion."|t, 113 | id: 'myTwigfield', 114 | name: 'myTwigfield', 115 | value: text, 116 | }) }} 117 | ``` 118 | 119 | ...where `text` is a variable containing the initial text that should be in the editor field. This will create the `label` and `instructions`, along with the Twig editor that is restricted to a single line, for simple Twig expressions. 120 | 121 | Regardless of the macro used, an Asset Bundle containing the necessary CSS & JavaScript for the editor to function will be included, and the editor initialized. 122 | 123 | ### In Frontend Templates 124 | 125 | By default, Twigfield will not work in frontend templates, unless you specifically enable it. 126 | 127 | Do so by copying the `config.php` file to the Craft CMS `config/` directory, renaming the file to `twigfield.php` in the process, then set the `allowFrontendAccess` setting to `true`: 128 | 129 | ```php 130 | return [ 131 | // Whether to allow anonymous access be allowed to the twigfield/autocomplete/index endpoint 132 | 'allowFrontendAccess' => true, 133 | // The default autcompletes to use for the default `Twigfield` field type 134 | 'defaultTwigfieldAutocompletes' => [ 135 | CraftApiAutocomplete::class, 136 | TwigLanguageAutocomplete::class, 137 | ] 138 | ]; 139 | ``` 140 | 141 | Then import the macros: 142 | 143 | ```twig 144 | {% import "twigfield/twigfield" as twigfield %} 145 | ``` 146 | 147 | Create your own ` 152 | {{ twigfield.includeJs("myTwigfield") }} 153 | ``` 154 | 155 | Enabling the `allowFrontendAccess` setting allows access to the `twigfield/autocomplete/index` endpoint, and add the `twigfield/templates` directory to the template roots. 156 | 157 | ### Additional Options 158 | 159 | The `textarea`, `textareaField`, `text`, `textField`, and `includeJs` macros all take four additional optional parameters: 160 | 161 | ```twig 162 | {{ textarea(config, fieldType, wrapperClass, editorOptions, twigfieldOptions) }} 163 | 164 | {{ textareaField(config, fieldType, wrapperClass, editorOptions, twigfieldOptions }} 165 | 166 | {{ text(config, fieldType, wrapperClass, editorOptions, twigfieldOptions) }} 167 | 168 | {{ textField(config, fieldType, wrapperClass, editorOptions, twigfieldOptions }} 169 | 170 | {{ includeJs(fieldId, fieldType, wrapperClass, editorOptions, twigfieldOptions }} 171 | ``` 172 | 173 | #### `fieldType` 174 | 175 | **`fieldType`** - an optional 2nd parameter. By default this is set to `Twigfield`. You only need to change it to something else if you're using a custom Autocomplete (see below) 176 | 177 | e.g.: 178 | 179 | ```twig 180 | {{ twigfield.textarea({ 181 | id: 'myTwigfield', 182 | name: 'myTwigfield', 183 | value: textAreaText, 184 | }), "MyCustomFieldType" }} 185 | ``` 186 | 187 | #### `wrapperClass` 188 | 189 | **`wrapperClass`** - an optional 3rd parameter. An additional class that is added to the Twigfield editor wrapper `div`. By default, this is an empty string. 190 | 191 | e.g.: 192 | 193 | ```twig 194 | {{ twigfield.textareaField({ 195 | label: "Twig Editor"|t, 196 | instructions: "Enter any Twig code below, with full API autocompletion."|t, 197 | id: 'myTwigfield', 198 | name: 'myTwigfield', 199 | value: textAreaText, 200 | }), "Twigfield", "monaco-editor-background-frame" }} 201 | ``` 202 | 203 | The `monaco-editor-background-frame` class is bundled, and will cause the field to look like a Craft CMS editor field, but you can use your own class as well. 204 | 205 | There also a `monaco-editor-inline-frame` bundled style for an inline editor in a table cell (or elsewhere that no chrome is desired). 206 | 207 | Both of these bundled styles use an accessibility focus ring when the editor is active, which mirrors the Craft CP style. 208 | 209 | #### `editorOptions` 210 | 211 | **`editorOptions`** - an optional 4th parameter. This is an [EditorOption](https://microsoft.github.io/monaco-editor/api/enums/monaco.editor.EditorOption.html) passed in to configure the Monaco editor. By default, this is an empty object. 212 | 213 | e.g.: 214 | 215 | ```html 216 | 218 | {{ twigfield.includeJs("myTwigfield", "Twigfield", "monaco-editor-background-frame", { 219 | lineNumbers: 'on', 220 | }) }} 221 | ``` 222 | 223 | #### `twigfieldOptions` 224 | 225 | **`twigfieldOptions`** - an optional 5th parameter. This object that can contain any data you want to pass from your Twig template down to the Autocomplete. This can be leveraged in custom Autocompletes to pass contextual for a particular field to the Autocomplete (see below) 226 | 227 | e.g.: 228 | 229 | ```twig 230 | {{ twigfield.textareaField({ 231 | label: "Twig Editor"|t, 232 | instructions: "Enter any Twig code below, with full API autocompletion."|t, 233 | id: 'myTwigfield', 234 | name: 'myTwigfield', 235 | value: textAreaText, 236 | }), "Twigfield", "monaco-editor-background-frame", { lineNumbers: 'on' }, { 237 | 'key': value, 238 | 'key2': value2, 239 | } }} 240 | ``` 241 | 242 | ## Using Additional Autocompletes 243 | 244 | By default, Twigfield uses the `CraftApiAutocomplete` & `TwigLanguageAutocomplete`, but it also includes an optional `EnvironmentVariableAutocomplete` which provides autocompletion of any Craft CMS [Environment Variables](https://craftcms.com/docs/4.x/config/#environmental-configuration) and [Aliases](https://craftcms.com/docs/4.x/config/#aliases). 245 | 246 | If you want to use the `EnvironmentVariableAutocomplete` or a custom Autocomplete you write, you'll need to add a little PHP code to your plugin, module, or project: 247 | 248 | ```php 249 | use nystudio107\twigfield\autocompletes\EnvironmentVariableAutocomplete; 250 | use nystudio107\twigfield\events\RegisterTwigfieldAutocompletesEvent; 251 | use nystudio107\twigfield\services\AutocompleteService; 252 | 253 | Event::on( 254 | AutocompleteService::class, 255 | AutocompleteService::EVENT_REGISTER_TWIGFIELD_AUTOCOMPLETES, 256 | function (RegisterTwigfieldAutocompletesEvent $event) { 257 | $event->types[] = EnvironmentVariableAutocomplete::class; 258 | } 259 | ); 260 | ``` 261 | 262 | The above code will add Environment Variable & Alias autocompletes to all of your Twigfield editors. 263 | 264 | However, because you might have several instances of a Twigfield on the same page, and they each may provide separate Autocompletes, you may want to selectively add a custom Autocomplete only when the `fieldType` matches a specific. 265 | 266 | Here's an example from the [Sprig plugin](https://github.com/putyourlightson/craft-sprig): 267 | 268 | ```php 269 | use nystudio107\twigfield\events\RegisterTwigfieldAutocompletesEvent; 270 | use nystudio107\twigfield\services\AutocompleteService; 271 | use putyourlightson\sprig\plugin\autocompletes\SprigApiAutocomplete; 272 | 273 | public const SPRIG_TWIG_FIELD_TYPE = 'SprigField'; 274 | 275 | Event::on( 276 | AutocompleteService::class, 277 | AutocompleteService::EVENT_REGISTER_TWIGFIELD_AUTOCOMPLETES, 278 | function (RegisterTwigfieldAutocompletesEvent $event) { 279 | if ($event->fieldType === self::SPRIG_TWIG_FIELD_TYPE) { 280 | $event->types[] = SprigApiAutocomplete::class; 281 | } 282 | } 283 | ); 284 | ``` 285 | 286 | This ensures that the `SprigApiAutocomplete` Autocomplete will only be added when the `fieldType` passed into the Twigfield macros is set to `SprigField`. 287 | 288 | Additionally, you may have an Autocomplete that you want to pass config information down to when it is instantiated. You can accomplish that by adding the Autocomplete as an array: 289 | 290 | ```php 291 | use nystudio107\twigfield\autocompletes\CraftApiAutocomplete; 292 | use nystudio107\twigfield\events\RegisterTwigfieldAutocompletesEvent; 293 | use nystudio107\twigfield\services\AutocompleteService; 294 | 295 | Event::on( 296 | AutocompleteService::class, 297 | AutocompleteService::EVENT_REGISTER_TWIGFIELD_AUTOCOMPLETES, 298 | function (RegisterTwigfieldAutocompletesEvent $event) { 299 | $config = [ 300 | 'additionalGlobals' => $arrayOfVariables, 301 | ]; 302 | $event->types[] = [CraftApiAutocomplete::class => $config]; 303 | } 304 | ); 305 | ``` 306 | 307 | Note that all of the above examples _add_ Autocompletes to the Autocompletes that Twigfield provides by default (`CraftApiAutocomplete` and `TwigLanguageAutocomplete`). If you want to _replace_ them entirely, just empty the `types[]` array first: 308 | 309 | ```php 310 | $event->types[] = []; 311 | $event->types[] = [CraftApiAutocomplete::class => $config]; 312 | ``` 313 | 314 | ## Writing a Custom Autocomplete 315 | 316 | Autocompletes extend from the base [Autocomplete](https://github.com/nystudio107/craft-twigfield/blob/develop/src/base/Autocomplete.php) class, and implement the [AutocompleteInterface](https://github.com/nystudio107/craft-twigfield/blob/develop/src/base/AutocompleteInterface.php) 317 | 318 | A simple Autocomplete would look like this: 319 | 320 | ```php 321 | label('MyAutocomplete') 341 | ->insertText('MyAutocomplete') 342 | ->detail('This is my autocomplete') 343 | ->documentation('This detailed documentation of my autocomplete') 344 | ->kind(CompleteItemKind::ConstantKind) 345 | ->add($this); 346 | } 347 | } 348 | ``` 349 | 350 | The `$name` property is the name of your Autocomplete, and it is used for the autocomplete cache. 351 | 352 | The `$type` property is either `AutocompleteTypes::TwigExpressionAutocomplete` (which only autocompletes inside of a Twig expression) or `AutocompleteTypes::GeneralAutocomplete` (which autocompletes everywhere). 353 | 354 | The `$hasSubProperties` property indicates whether your Autocomplete returns nested sub-properties such as `foo.bar.baz`. This hint helps Twigfield present a better autocomplete experience. 355 | 356 | `CompleteItem::create()` is a factory method that creates a `CompleteItem` object. You can use the Fluent Model setters as shown above, or you can set properties directly on the model as well. The `CompleteItem::add()` method adds it to the list of generated Autocompletes. 357 | 358 | Your Autocomplete also has a `$twigfieldOptions` property which will contain any data passed down via the optional 5th `twigfieldOptions` parameter from your Twig template. This allows you to have contextual information this a particular field. 359 | 360 | See the following examples for custom Autocompletes that you can use as a guide when creating your own: 361 | 362 | * [TrackingVarsAutocomplete](https://github.com/nystudio107/craft-seomatic/blob/develop/src/autocompletes/TrackingVarsAutocomplete.php) 363 | * [SprigApiAutocomplete](https://github.com/putyourlightson/craft-sprig/blob/develop/src/autocompletes/SprigApiAutocomplete.php) 364 | * [CraftApiAutocomplete](https://github.com/nystudio107/craft-twigfield/blob/develop/src/autocompletes/CraftApiAutocomplete.php) 365 | * [EnvironmentVariableAutocomplete](https://github.com/nystudio107/craft-twigfield/blob/develop/src/autocompletes/EnvironmentVariableAutocomplete.php) 366 | * [TwigLanguageAutocomplete](https://github.com/nystudio107/craft-twigfield/blob/develop/src/autocompletes/TwigLanguageAutocomplete.php) 367 | 368 | ## Twig Template Validators 369 | 370 | Twigfield also includes two Twig template [Validators](https://www.yiiframework.com/doc/guide/2.0/en/tutorial-core-validators) that you can use to validate Twig templates that are saved as part of a model: 371 | 372 | * [TwigTemplateValidator](https://github.com/nystudio107/craft-twigfield/blob/develop/src/validators/TwigTemplateValidator.php) - validates the template via `renderString()` 373 | * [TwigObjectTemplateValidator](https://github.com/nystudio107/craft-twigfield/blob/develop/src/validators/TwigObjectTemplateValidator.php) - validates the template via `renderObjectTemplate()` 374 | 375 | You just add them as a rule on your model, and it will propagate the model with any errors that were encountered when rendering the template: 376 | 377 | ```php 378 | use nystudio107\twigfield\validators\TwigTemplateValidator; 379 | 380 | public function defineRules() 381 | { 382 | return [ 383 | ['myTwigCode', TwigTemplateValidator::class], 384 | ]; 385 | } 386 | ``` 387 | 388 | You can also add in any `variables` that should be presents in the Twig environment: 389 | 390 | ```php 391 | use nystudio107\twigfield\validators\TwigTemplateValidator; 392 | 393 | public function defineRules() 394 | { 395 | return [ 396 | [ 397 | 'myTwigCode', TwigTemplateValidator::class, 398 | 'variables' => [ 399 | 'foo' => 'bar', 400 | ] 401 | ], 402 | ]; 403 | } 404 | ``` 405 | 406 | For the `TwigObjectTemplateValidator`, you can also pass in the `object` that should be used when rendering the object template: 407 | 408 | ```php 409 | use nystudio107\twigfield\validators\TwigObjectTemplateValidator; 410 | 411 | public function defineRules() 412 | { 413 | return [ 414 | [ 415 | 'myTwigCode', TwigObjectTemplateValidator::class, 416 | 'object' => $object, 417 | 'variables' => [ 418 | 'foo' => 'bar', 419 | ] 420 | ], 421 | ]; 422 | } 423 | ``` 424 | 425 | ## Twigfield Roadmap 426 | 427 | Some things to do, and ideas for potential features: 428 | 429 | * Perhaps a general code editor as an offshoot? 430 | * Add a handler for parsing method return parameters, so we can get autocomplete on things like `craft.app.getSecurity().` 431 | * Figure out why the suggestions details sub-window doesn't appear to size itself properly to fit the `documentation`. It's there, but you have to resize the window to see it, and it appears to be calculated incorrectly somehow 432 | * Smarter Twig expression detection 433 | * Hovers for `TwigExpressionAutocomplete`s should only be added if they are inside of a Twig expression 434 | * It would be nice if `SectionShorthandFieldsAutocomplete` completions presented sub-item completions, too 435 | 436 | Brought to you by [nystudio107](https://nystudio107.com/) 437 | -------------------------------------------------------------------------------- /src/autocompletes/TwigLanguageAutocomplete.php: -------------------------------------------------------------------------------- 1 | '[abs](https://twig.symfony.com/doc/3.x/filters/abs.html) | Returns the absolute value of a number.', 33 | 'address' => '[address](#address) | Formats an address.', 34 | 'append' => '[append](#append) | Appends HTML to the end of another element.', 35 | 'ascii' => '[ascii](#ascii) | Converts a string to ASCII characters.', 36 | 'atom' => '[atom](#atom) | Converts a date to an ISO-8601 timestamp.', 37 | 'attr' => '[attr](#attr) | Modifies an HTML tag’s attributes.', 38 | 'batch' => '[batch](https://twig.symfony.com/doc/3.x/filters/batch.html) | Batches items in an array.', 39 | 'camel' => '[camel](#camel) | Formats a string into camelCase.', 40 | 'capitalize' => '[capitalize](https://twig.symfony.com/doc/3.x/filters/capitalize.html) | Capitalizes the first character of a string.', 41 | 'column' => '[column](#column) | Returns the values from a single property or key in an array.', 42 | 'contains' => '[contains](#contains) | Returns whether an array contains a nested item with a given key-value pair.', 43 | 'convert_encoding' => '[convert_encoding](https://twig.symfony.com/doc/3.x/filters/convert_encoding.html) | Converts a string from one encoding to another.', 44 | 'currency' => '[currency](#currency) | Formats a number as currency.', 45 | 'date' => '[date](#date) | Formats a date.', 46 | 'date_modify' => '[date_modify](https://twig.symfony.com/doc/3.x/filters/date_modify.html) | Modifies a date.', 47 | 'datetime' => '[datetime](#datetime) | Formats a date with its time.', 48 | 'default' => '[default](https://twig.symfony.com/doc/3.x/filters/default.html) | Returns the value or a default value if empty.', 49 | 'diff' => '[diff](#diff) | Returns the difference between arrays.', 50 | 'duration' => '[duration](#duration) | Returns a `DateInterval` object.', 51 | 'e' => '[e](https://twig.symfony.com/doc/3.x/filters/escape.html) | Escapes a string.', 52 | 'encenc' => '[encenc](#encenc) | Encrypts and base64-encodes a string.', 53 | 'escape' => '[escape](https://twig.symfony.com/doc/3.x/filters/escape.html) | Escapes a string.', 54 | 'explodeClass' => '[explodeClass](#explodeclass) | Converts a `class` attribute value into an array of class names.', 55 | 'explodeStyle' => '[explodeStyle](#explodestyle) | Converts a `style` attribute value into an array of property name/value pairs.', 56 | 'filesize' => '[filesize](#filesize) | Formats a number of bytes into something else.', 57 | 'filter' => '[filter](#filter) | Filters the items in an array.', 58 | 'first' => '[first](https://twig.symfony.com/doc/3.x/filters/first.html) | Returns the first character/item of a string/array.', 59 | 'format' => '[format](https://twig.symfony.com/doc/3.x/filters/format.html) | Formats a string by replacing placeholders.', 60 | 'group' => '[group](#group) | Groups items in an array.', 61 | 'hash' => '[hash](#hash) | Prefixes a string with a keyed-hash message authentication code (HMAC).', 62 | 'httpdate' => '[httpdate](#httpdate) | Converts a date to the HTTP format.', 63 | 'id' => '[id](#id) | Normalizes an element ID into only alphanumeric characters, underscores, and dashes.', 64 | 'index' => '[index](#index) | Indexes the items in an array.', 65 | 'indexOf' => '[indexOf](#indexof) | Returns the index of a given value within an array, or the position of a passed-in string within another string.', 66 | 'intersect' => '[intersect](#intersect) | Returns the intersecting items of two arrays.', 67 | 'join' => '[join](https://twig.symfony.com/doc/3.x/filters/join.html) | Concatenates multiple strings into one.', 68 | 'json_decode' => '[json_decode](#json_decode) | JSON-decodes a value.', 69 | 'json_encode' => '[json_encode](#json_encode) | JSON-encodes a value.', 70 | 'kebab' => '[kebab](#kebab) | Formats a string into “kebab-case”.', 71 | 'keys' => '[keys](https://twig.symfony.com/doc/3.x/filters/keys.html) | Returns the keys of an array.', 72 | 'last' => '[last](https://twig.symfony.com/doc/3.x/filters/last.html) | Returns the last character/item of a string/array.', 73 | 'lcfirst' => '[lcfirst](#lcfirst) | Lowercases the first character of a string.', 74 | 'length' => '[length](https://twig.symfony.com/doc/3.x/filters/length.html) | Returns the length of a string or array.', 75 | 'literal' => '[literal](#literal) | Escapes an untrusted string for use with element query params.', 76 | 'lower' => '[lower](https://twig.symfony.com/doc/3.x/filters/lower.html) | Lowercases a string.', 77 | 'map' => '[map](https://twig.symfony.com/doc/3.x/filters/map.html) | Applies an arrow function to the items in an array.', 78 | 'markdown' => '[markdown](#markdown-or-md) | Processes a string as Markdown.', 79 | 'md' => '[md](#markdown-or-md) | Processes a string as Markdown.', 80 | 'merge' => '[merge](#merge) | Merges an array with another one.', 81 | 'money' => '[money](#money) | Outputs a value from a Money object.', 82 | 'multisort' => '[multisort](#multisort) | Sorts an array by one or more keys within its sub-arrays.', 83 | 'namespace' => '[namespace](#namespace) | Namespaces input names and other HTML attributes, as well as CSS selectors.', 84 | 'namespaceAttributes' => '', 85 | 'namespaceInputId' => '[namespaceInputId](#namespaceinputid) | Namespaces an element ID.', 86 | 'namespaceInputName' => '[namespaceInputName](#namespaceinputname) | Namespaces an input name.', 87 | 'nl2br' => '[nl2br](https://twig.symfony.com/doc/3.x/filters/nl2br.html) | Replaces newlines with `
` tags.', 88 | 'ns' => '[ns](#namespace) | Namespaces input names and other HTML attributes, as well as CSS selectors.', 89 | 'number' => '[number](#number) | Formats a number.', 90 | 'number_format' => '[number_format](https://twig.symfony.com/doc/3.x/filters/number_format.html) | Formats numbers.', 91 | 'parseAttr' => '', 92 | 'parseRefs' => '[parseRefs](#parserefs) | Parses a string for reference tags.', 93 | 'pascal' => '[pascal](#pascal) | Formats a string into “PascalCase”.', 94 | 'percentage' => '[percentage](#percentage) | Formats a percentage.', 95 | 'prepend' => '[prepend](#prepend) | Prepends HTML to the beginning of another element.', 96 | 'purify' => '[purify](#purify) | Runs HTML code through HTML Purifier.', 97 | 'push' => '[push](#push) | Appends one or more items onto the end of an array.', 98 | 'raw' => '[raw](https://twig.symfony.com/doc/3.x/filters/raw.html) | Marks as value as safe for the current escaping strategy.', 99 | 'reduce' => '[reduce](https://twig.symfony.com/doc/3.x/filters/reduce.html) | Iteratively reduces a sequence or mapping to a single value.', 100 | 'removeClass' => '[removeClass](#removeclass) | Removes a class (or classes) from the given HTML tag.', 101 | 'replace' => '[replace](#replace) | Replaces parts of a string with other things.', 102 | 'reverse' => '[reverse](https://twig.symfony.com/doc/3.x/filters/reverse.html) | Reverses a string or array.', 103 | 'round' => '[round](https://twig.symfony.com/doc/3.x/filters/round.html) | Rounds a number.', 104 | 'rss' => '[rss](#rss) | Converts a date to RSS date format.', 105 | 'slice' => '[slice](https://twig.symfony.com/doc/3.x/filters/slice.html) | Extracts a slice of a string or array.', 106 | 'snake' => '[snake](#snake) | Formats a string into “snake_case”.', 107 | 'sort' => '[sort](https://twig.symfony.com/doc/3.x/filters/sort.html) | Sorts an array.', 108 | 'spaceless' => '[spaceless](https://twig.symfony.com/doc/3.x/filters/spaceless.html) | Removes whitespace between HTML tags.', 109 | 'split' => '[split](https://twig.symfony.com/doc/3.x/filters/split.html) | Splits a string by a delimiter.', 110 | 'striptags' => '[striptags](https://twig.symfony.com/doc/3.x/filters/striptags.html) | Strips SGML/XML tags from a string.', 111 | 't' => '[t](#translate-or-t) | Translates a message.', 112 | 'time' => '[time](#time) | Formats a time.', 113 | 'timestamp' => '[timestamp](#timestamp) | Formats a human-readable timestamp.', 114 | 'title' => '[title](https://twig.symfony.com/doc/3.x/filters/title.html) | Formats a string into “Title Case”.', 115 | 'translate' => '[translate](#translate-or-t) | Translates a message.', 116 | 'trim' => '[trim](https://twig.symfony.com/doc/3.x/filters/trim.html) | Strips whitespace from the beginning and end of a string.', 117 | 'truncate' => '[truncate](#truncate) | Truncates a string to a given length, while ensuring that it does not split words.', 118 | 'ucfirst' => '[ucfirst](#ucfirst) | Capitalizes the first character of a string.', 119 | 'ucwords' => '', 120 | 'unique' => '[unique](#unique) | Removes duplicate values from an array.', 121 | 'unshift' => '[unshift](#unshift) | Prepends one or more items to the beginning of an array.', 122 | 'upper' => '[upper](https://twig.symfony.com/doc/3.x/filters/upper.html) | Formats a string into “UPPER CASE”.', 123 | 'url_encode' => '[url_encode](https://twig.symfony.com/doc/3.x/filters/url_encode.html) | Percent-encodes a string as a URL segment or an array as a query string.', 124 | 'values' => '[values](#values) | Returns all the values in an array, resetting its keys.', 125 | 'where' => '[where](#where) | Filters an array by key-value pairs.', 126 | 'widont' => '', 127 | 'without' => '[without](#without) | Returns an array without the specified element(s).', 128 | 'withoutKey' => '[withoutKey](#withoutkey) | Returns an array without the specified key.', 129 | ]; 130 | 131 | const CRAFT_FUNCTION_DOCS_URL = 'https://craftcms.com/docs/4.x/dev/functions.html'; 132 | const FUNCTION_DOCS = [ 133 | 'actionInput' => '[actionInput](#actioninput) | Outputs a hidden `action` input.', 134 | 'actionUrl' => '[actionUrl](#actionurl) | Generates a controller action URL.', 135 | 'alias' => '[alias](#alias) | Parses a string as an alias.', 136 | 'attr' => '[attr](#attr) | Generates HTML attributes.', 137 | 'beginBody' => '[beginBody](#beginbody) | Outputs scripts and styles that were registered for the “begin body” position.', 138 | 'ceil' => '[ceil](#ceil) | Rounds a number up.', 139 | 'className' => '[className](#classname) | Returns the fully qualified class name of a given object.', 140 | 'clone' => '[clone](#clone) | Clones an object.', 141 | 'collect' => '[collect](#collect) | Returns a new collection.', 142 | 'combine' => '[combine](#combine) | Combines two arrays into one.', 143 | 'configure' => '[configure](#configure) | Sets attributes on the passed object.', 144 | 'constant' => '[constant](https://twig.symfony.com/doc/3.x/functions/constant.html) | Returns the constant value for a given string.', 145 | 'cpUrl' => '[cpUrl](#cpurl) | Generates a control panel URL.', 146 | 'create' => '[create](#create) | Creates a new object.', 147 | 'csrfInput' => '[csrfInput](#csrfinput) | Returns a hidden CSRF token input.', 148 | 'cycle' => '[cycle](https://twig.symfony.com/doc/3.x/functions/cycle.html) | Cycles on an array of values.', 149 | 'dataUrl' => '[dataUrl](#dataurl) | Outputs an asset or file as a base64-encoded data URL.', 150 | 'date' => '[date](#date) | Creates a date.', 151 | 'dump' => '[dump](https://twig.symfony.com/doc/3.x/functions/dump.html) | Dumps information about a variable.', 152 | 'endBody' => '[endBody](#endbody) | Outputs scripts and styles that were registered for the “end body” position.', 153 | 'expression' => '[expression](#expression) | Creates a database expression object.', 154 | 'failMessageInput' => '[failMessageInput](#failmessageinput) | Outputs a hidden `failMessage` input.', 155 | 'floor' => '[floor](#floor) | Rounds a number down.', 156 | 'getenv' => '[getenv](#getenv) | Returns the value of an environment variable.', 157 | 'gql' => '[gql](#gql) | Executes a GraphQL query against the full schema.', 158 | 'head' => '[head](#head) | Outputs scripts and styles that were registered for the “head” position.', 159 | 'hiddenInput' => '[hiddenInput](#hiddeninput) | Outputs a hidden input.', 160 | 'include' => '[include](https://twig.symfony.com/doc/3.x/functions/include.html) | Returns the rendered content of a template.', 161 | 'input' => '[input](#input) | Outputs an HTML input.', 162 | 'max' => '[max](https://twig.symfony.com/doc/3.x/functions/max.html) | Returns the biggest value in an array.', 163 | 'min' => '[min](https://twig.symfony.com/doc/3.x/functions/min.html) | Returns the lowest value in an array.', 164 | 'ol' => '[ol](#ol) | Outputs an array of items as an ordered list.', 165 | 'parseBooleanEnv' => '[parseBooleanEnv](#parsebooleanenv) | Parses a string as an environment variable or alias having a boolean value.', 166 | 'parseEnv' => '[parseEnv](#parseenv) | Parses a string as an environment variable or alias.', 167 | 'plugin' => '[plugin](#plugin) | Returns a plugin instance by its handle.', 168 | 'random' => '[random](https://twig.symfony.com/doc/3.x/functions/random.html) | Returns a random value.', 169 | 'range' => '[range](https://twig.symfony.com/doc/3.x/functions/range.html) | Returns a list containing an arithmetic progression of integers.', 170 | 'raw' => '[raw](#raw) | Wraps the given string in a `Twig\Markup` object to prevent it from getting HTML-encoded when output.', 171 | 'redirectInput' => '[redirectInput](#redirectinput) | Outputs a hidden `redirect` input.', 172 | 'renderObjectTemplate' => '', 173 | 'seq' => '[seq](#seq) | Outputs the next or current number in a sequence.', 174 | 'shuffle' => '[shuffle](#shuffle) | Randomizes the order of the items in an array.', 175 | 'siteUrl' => '[siteUrl](#siteurl) | Generates a front-end URL.', 176 | 'source' => '[source](https://twig.symfony.com/doc/3.x/functions/source.html) | Returns the content of a template without rendering it.', 177 | 'successMessageInput' => '[successMessageInput](#successmessageinput) | Outputs a hidden `successMessage` input.', 178 | 'svg' => '[svg](#svg) | Outputs an SVG document.', 179 | 'tag' => '[tag](#tag) | Outputs an HTML tag.', 180 | 'template_from_string' => '[template_from_string](https://twig.symfony.com/doc/3.x/functions/template_from_string.html) | Loads a template from a string.', 181 | 'ul' => '[ul](#ul) | Outputs an array of items as an unordered list.', 182 | 'url' => '[url](#url) | Generates a URL.', 183 | ]; 184 | 185 | const CRAFT_TAG_DOCS_URL = 'https://craftcms.com/docs/4.x/dev/tags.html'; 186 | const TAG_DOCS = [ 187 | 'apply' => '[apply](https://twig.symfony.com/doc/3.x/tags/apply.html) | Applies Twig filters to the nested template code.', 188 | 'autoescape' => '[autoescape](https://twig.symfony.com/doc/3.x/tags/autoescape.html) | Controls the escaping strategy for the nested template code.', 189 | 'block' => '[block](https://twig.symfony.com/doc/3.x/tags/block.html) | Defines a template block.', 190 | 'cache' => '[cache](#cache) | Caches a portion of your template.', 191 | 'css' => '[css](#css) | Registers a `