├── .styleci.yml ├── config └── config.php ├── CHANGELOG.md ├── src ├── translations │ ├── en │ │ └── form.php │ └── fr │ │ └── form.php ├── InlineTranslationController.php ├── InlineTranslationServiceProvider.php ├── InlineTranslationMiddleware.php ├── InlineTranslation.php └── TemplateParser.php ├── LICENSE.md ├── composer.json ├── README.md ├── CONTRIBUTING.md └── resources └── views ├── translation.blade.php └── featherlight.php /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | 3 | disabled: 4 | - single_class_element_per_statement 5 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | env('INLINE_TRANSLATION_ENABLED', true) 5 | ]; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-inline-translation` will be documented in this file 4 | 5 | ## 1.0.0 - 201X-XX-XX 6 | 7 | - initial release 8 | -------------------------------------------------------------------------------- /src/translations/en/form.php: -------------------------------------------------------------------------------- 1 | 'save', 4 | 'translate' => 'translate', 5 | 'value' => 'value', 6 | 'translated' => 'translated', 7 | 'original' => 'Original', 8 | 'location' => 'Location', 9 | 'parameters' => 'Parameters', 10 | ]; -------------------------------------------------------------------------------- /src/translations/fr/form.php: -------------------------------------------------------------------------------- 1 | 'save', 4 | 'translate' => 'translate', 5 | 'value' => 'Valeur', 6 | 'translated' => 'traduction', 7 | 'original' => 'Originale', 8 | 'location' => 'Location', 9 | 'parameters' => 'Parameteres', 10 | ]; 11 | -------------------------------------------------------------------------------- /src/InlineTranslationController.php: -------------------------------------------------------------------------------- 1 | updateTranslationFiles(app()->getLocale(), $request->get('key'), $request->get('value')); 14 | } 15 | 16 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Beyond Code GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beyondcode/laravel-inline-translation", 3 | "description": "Add inline translation capabilities to your Laravel application.", 4 | "keywords": [ 5 | "beyondcode", 6 | "laravel-inline-translation" 7 | ], 8 | "homepage": "https://github.com/beyondcode/laravel-inline-translation", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Marcel Pociot", 13 | "email": "marcel@beyondco.de", 14 | "homepage": "https://beyondcode.de", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.1" 20 | }, 21 | "require-dev": { 22 | "larapack/dd": "^1.0", 23 | "orchestra/testbench": "^3.6", 24 | "phpunit/phpunit": "^7.0" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "BeyondCode\\InlineTranslation\\": "src" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "BeyondCode\\InlineTranslation\\Tests\\": "tests" 34 | } 35 | }, 36 | "scripts": { 37 | "test": "vendor/bin/phpunit", 38 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 39 | 40 | }, 41 | "config": { 42 | "sort-packages": true 43 | }, 44 | "extra": { 45 | "laravel": { 46 | "providers": [ 47 | "BeyondCode\\InlineTranslation\\InlineTranslationServiceProvider" 48 | ], 49 | "aliases": { 50 | "InlineTranslation": "BeyondCode\\InlineTranslation\\InlineTranslationFacade" 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/InlineTranslationServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 16 | $this->publishes([ 17 | __DIR__.'/../config/config.php' => config_path('inline-translation.php'), 18 | ], 'config'); 19 | } 20 | 21 | $this->loadTranslationsFrom(__DIR__.'/translations', 'laravel-inline-translation'); 22 | $this->loadViewsFrom(__DIR__.'/../resources/views', 'inline-translation'); 23 | 24 | $this->registerMiddleware(InlineTranslationMiddleware::class); 25 | 26 | $this->addRoute(); 27 | } 28 | 29 | /** 30 | * Register the application services. 31 | */ 32 | public function register() 33 | { 34 | $this->mergeConfigFrom(__DIR__ . '/../config/config.php', 'inline-translation'); 35 | 36 | $this->app->singleton(InlineTranslation::class); 37 | } 38 | 39 | 40 | /** 41 | * Register the middleware 42 | * 43 | * @param string $middleware 44 | */ 45 | protected function registerMiddleware($middleware) 46 | { 47 | $kernel = $this->app[Kernel::class]; 48 | $kernel->pushMiddleware($middleware); 49 | } 50 | 51 | protected function addRoute() 52 | { 53 | app('router')->post('/_beyondcode/translation', InlineTranslationController::class.'@store'); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/InlineTranslationMiddleware.php: -------------------------------------------------------------------------------- 1 | inlineTranslation = $inlineTranslation; 15 | } 16 | 17 | /** 18 | * Handle an incoming request. 19 | * 20 | * @param Request $request 21 | * @param Closure $next 22 | * @return mixed 23 | */ 24 | public function handle($request, Closure $next) 25 | { 26 | if (! $this->inlineTranslation->isEnabled()) { 27 | return $next($request); 28 | } 29 | 30 | $this->inlineTranslation->boot(); 31 | 32 | $response = $next($request); 33 | 34 | if ($response->isRedirection()) { 35 | return $response; 36 | } elseif ( 37 | ($response->headers->has('Content-Type') && 38 | strpos($response->headers->get('Content-Type'), 'html') === false) 39 | || $request->getRequestFormat() !== 'html' 40 | || $response->getContent() === false 41 | ) { 42 | return $response; 43 | } elseif (is_null($response->exception)) { 44 | $this->injectTranslationView($response); 45 | } 46 | 47 | return $response; 48 | } 49 | 50 | protected function injectTranslationView($response) 51 | { 52 | $content = $response->getContent(); 53 | 54 | $renderedContent = view('inline-translation::translation'); 55 | 56 | $pos = strripos($content, ''); 57 | 58 | if (false !== $pos) { 59 | $content = substr($content, 0, $pos) . $renderedContent . substr($content, $pos); 60 | } else { 61 | $content = $content . $renderedContent; 62 | } 63 | 64 | // Update the new content and reset the content length 65 | $response->setContent($content); 66 | $response->headers->remove('Content-Length'); 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Inline Translation 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/beyondcode/laravel-inline-translation.svg?style=flat-square)](https://packagist.org/packages/beyondcode/laravel-inline-translation) 4 | [![Build Status](https://img.shields.io/travis/beyondcode/laravel-inline-translation/master.svg?style=flat-square)](https://travis-ci.org/beyondcode/laravel-inline-translation) 5 | [![Quality Score](https://img.shields.io/scrutinizer/g/beyondcode/laravel-inline-translation.svg?style=flat-square)](https://scrutinizer-ci.com/g/beyondcode/laravel-inline-translation) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/beyondcode/laravel-inline-translation.svg?style=flat-square)](https://packagist.org/packages/beyondcode/laravel-inline-translation) 7 | 8 | This package lets you add inline translation to your Laravel application. Just click on a translation variable, change it's value and save the new value. 9 | 10 | ![Example output](https://beyondco.de/github/inline-translation/example.png) 11 | 12 | 13 | ## Installation 14 | 15 | You can install the package via composer as a dev dependency: 16 | 17 | ```bash 18 | composer require beyondcode/laravel-inline-translation --dev 19 | ``` 20 | 21 | The package is enabled by default - so all you need to do is visit your application in the browser and look for translation keys. 22 | 23 | Please do **NOT** use this package in production. Updating translation keys will save the updated values in the filesystem. 24 | This package is only intended during the development. 25 | 26 | ## Disabling Inline Translation 27 | 28 | You can disable inline translation by setting an environment variable called `INLINE_TRANSLATION_ENABLED` to `false`. 29 | 30 | ### Disclaimer 31 | 32 | I tested this package with a couple of our client projects as well as with some open source Laravel projects. 33 | Translation variables appear throughout very different parts of your application, so there is a chance that this is not working for your specific setup. 34 | 35 | ### Changelog 36 | 37 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 38 | 39 | ## Contributing 40 | 41 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 42 | 43 | ### Security 44 | 45 | If you discover any security related issues, please email marcel@beyondco.de instead of using the issue tracker. 46 | 47 | ## Credits 48 | 49 | - [Marcel Pociot](https://github.com/mpociot) 50 | - [All Contributors](../../contributors) 51 | 52 | ## License 53 | 54 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 55 | -------------------------------------------------------------------------------- /src/InlineTranslation.php: -------------------------------------------------------------------------------- 1 | getPath()); 25 | 26 | $file = tempnam(sys_get_temp_dir(), $view->name()); 27 | 28 | (new TemplateParser($view->getData()))->parseTranslationTags($viewContent); 29 | 30 | file_put_contents($file, $viewContent); 31 | 32 | $view->setPath($file); 33 | }); 34 | } 35 | 36 | /** 37 | * @param string $locale 38 | * @param string $key 39 | * @param string $value 40 | */ 41 | public function updateTranslationFiles($locale, $key, $value) 42 | { 43 | // Try JSON lookup first. 44 | $this->updateJsonLanguageFile(resource_path('lang/' . $locale . '.json'), $key, $value); 45 | 46 | // JSON does not exist - try php based language files 47 | list($namespace, $group, $item) = app(NamespacedItemResolver::class)->parseKey($key); 48 | 49 | if (is_null($namespace) || $namespace == '*') { 50 | $this->updateRequiredLanguageFile(resource_path("lang/$locale/$group.php"), $item, $value); 51 | } else { 52 | $this->updateRequiredLanguageFile(resource_path("lang/vendor/$namespace/$locale/$group.php"), $item, $value); 53 | } 54 | } 55 | 56 | /** 57 | * @param string $path 58 | * @param string $item 59 | * @param string $value 60 | */ 61 | private function updateRequiredLanguageFile($path, $item, $value) 62 | { 63 | if (File::exists($path)) { 64 | $content = File::getRequire($path); 65 | 66 | if (array_has($content, $item)) { 67 | $content[$item] = $value; 68 | 69 | File::put($path, ' 2 | 3 | 4 | @include('inline-translation::featherlight') 5 | 6 | 7 | 8 | 22 | 124 | -------------------------------------------------------------------------------- /src/TemplateParser.php: -------------------------------------------------------------------------------- 1 | 'Caption for the fieldset element', 23 | 'label' => 'Label for an input element.', 24 | 'button' => 'Push button', 25 | 'a' => 'Link label', 26 | 'b' => 'Bold text', 27 | 'strong' => 'Strong emphasized text', 28 | 'i' => 'Italic text', 29 | 'em' => 'Emphasized text', 30 | 'u' => 'Underlined text', 31 | 'sup' => 'Superscript text', 32 | 'sub' => 'Subscript text', 33 | 'span' => 'Span element', 34 | 'small' => 'Smaller text', 35 | 'big' => 'Bigger text', 36 | 'address' => 'Contact information', 37 | 'blockquote' => 'Long quotation', 38 | 'q' => 'Short quotation', 39 | 'cite' => 'Citation', 40 | 'caption' => 'Table caption', 41 | 'abbr' => 'Abbreviated phrase', 42 | 'acronym' => 'An acronym', 43 | 'var' => 'Variable part of a text', 44 | 'dfn' => 'Term', 45 | 'strike' => 'Strikethrough text', 46 | 'del' => 'Deleted text', 47 | 'ins' => 'Inserted text', 48 | 'h1' => 'Heading level 1', 49 | 'h2' => 'Heading level 2', 50 | 'h3' => 'Heading level 3', 51 | 'h4' => 'Heading level 4', 52 | 'h5' => 'Heading level 5', 53 | 'h6' => 'Heading level 6', 54 | 'center' => 'Centered text', 55 | 'select' => 'List options', 56 | 'img' => 'Image', 57 | 'input' => 'Form element', 58 | 'p' => 'Generic Paragraph', 59 | ]; 60 | 61 | /** 62 | * TemplateParser constructor. 63 | * @param array $viewData 64 | */ 65 | public function __construct(array $viewData = []) 66 | { 67 | $this->viewData = $viewData; 68 | } 69 | 70 | public function parseTranslationTags(string &$viewContent) 71 | { 72 | $nextTag = 0; 73 | 74 | $tags = implode('|', array_keys($this->allowedTags)); 75 | 76 | $tagRegExp = '#<(' . $tags . ')(/?>| \s*[^>]*+/?>)#iSU'; 77 | 78 | $tagMatch = []; 79 | 80 | while (preg_match($tagRegExp, $viewContent, $tagMatch, PREG_OFFSET_CAPTURE, $nextTag)) { 81 | $tagName = strtolower($tagMatch[1][0]); 82 | 83 | if (substr($tagMatch[0][0], -2) == '/>') { 84 | $tagClosurePos = $tagMatch[0][1] + strlen($tagMatch[0][0]); 85 | } else { 86 | $tagClosurePos = $this->findEndOfTag($viewContent, $tagName, $tagMatch[0][1]); 87 | } 88 | 89 | if ($tagClosurePos === false) { 90 | $nextTag += strlen($tagMatch[0][0]); 91 | continue; 92 | } 93 | 94 | $tagLength = $tagClosurePos - $tagMatch[0][1]; 95 | $tagStartLength = strlen($tagMatch[0][0]); 96 | 97 | $tagHtml = $tagMatch[0][0] . substr( 98 | $viewContent, 99 | $tagMatch[0][1] + $tagStartLength, 100 | $tagLength - $tagStartLength 101 | ); 102 | 103 | $tagClosurePos = $tagMatch[0][1] + strlen($tagHtml); 104 | 105 | $trArr = $this->getTranslateData( 106 | '/({{|{!!)\s*(__|\@lang|\@trans|\@choice|trans|trans_choice)\((.*?)\)\s*(}}|!!})/m', 107 | $tagHtml, 108 | ['tagName' => $tagName, 'tagList' => $this->allowedTags] 109 | ); 110 | 111 | if (!empty($trArr)) { 112 | $trArr = array_unique($trArr); 113 | 114 | $tagHtml = $this->applyTranslationTags($tagHtml, $tagName, $trArr); 115 | 116 | $tagClosurePos = $tagMatch[0][1] + strlen($tagHtml); 117 | $viewContent = substr_replace($viewContent, $tagHtml, $tagMatch[0][1], $tagLength); 118 | } 119 | 120 | $nextTag = $tagClosurePos; 121 | } 122 | } 123 | 124 | /** 125 | * Format translation for simple tags. Added translate mode attribute for vde requests. 126 | * 127 | * @param string $tagHtml 128 | * @param string $tagName 129 | * @param array $trArr 130 | * @return string 131 | */ 132 | protected function applyTranslationTags($tagHtml, $tagName, $trArr) 133 | { 134 | $simpleTags = substr( 135 | $tagHtml, 136 | 0, 137 | strlen($tagName) + 1 138 | ) . ' ' . $this->getHtmlAttribute( 139 | self::DATA_TRANSLATE, 140 | htmlspecialchars('[' . join(',', $trArr) . ']') 141 | ); 142 | 143 | $simpleTags .= substr($tagHtml, strlen($tagName) + 1); 144 | return $simpleTags; 145 | } 146 | 147 | /** 148 | * Get html element attribute 149 | * 150 | * @param string $name 151 | * @param string $value 152 | * @return string 153 | */ 154 | private function getHtmlAttribute($name, $value) 155 | { 156 | return $name . '="' . $value . '"'; 157 | } 158 | 159 | /** 160 | * Get translate data by regexp 161 | * 162 | * @param string $regexp 163 | * @param string $text 164 | * @param array $options 165 | * @return array 166 | */ 167 | private function getTranslateData($regexp, $text, $options = []) 168 | { 169 | $trArr = []; 170 | $nextRegexOffset = 0; 171 | while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE, $nextRegexOffset)) { 172 | 173 | extract($this->viewData, EXTR_OVERWRITE); 174 | $translationParameters = $this->extractVariablesFromLocalizationParameters($matches[3][0]); 175 | 176 | try { 177 | $translated = eval('return ' . $matches[2][0] . '(' . $matches[3][0] . ');'); 178 | } catch (Exception $e) { 179 | $translated = ''; 180 | } 181 | 182 | $trArr[] = json_encode( 183 | [ 184 | 'translated' => $translated, 185 | 'original' => $translationParameters[0], 186 | 'parameters' => $translationParameters[1] ?? [], 187 | 'location' => htmlspecialchars_decode($this->getTagLocation($matches, $options)), 188 | ] 189 | ); 190 | $nextRegexOffset = $matches[4][1]; 191 | } 192 | return $trArr; 193 | } 194 | 195 | /** 196 | * Get tag location 197 | * 198 | * @param array $matches 199 | * @param array $options 200 | * @return string 201 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 202 | */ 203 | protected function getTagLocation($matches, $options) 204 | { 205 | $tagName = strtolower($options['tagName']); 206 | if (isset($options['tagList'][$tagName])) { 207 | return $options['tagList'][$tagName]; 208 | } 209 | return ucfirst($tagName) . ' Text'; 210 | } 211 | 212 | private function _returnViewData() 213 | { 214 | $args = func_get_args(); 215 | $return = []; 216 | 217 | foreach ($args as $argument) { 218 | $return[] = $argument; 219 | } 220 | 221 | return $return; 222 | } 223 | 224 | /** 225 | * @param string $localizationParameters 226 | * @return array|mixed 227 | */ 228 | private function extractVariablesFromLocalizationParameters($localizationParameters) 229 | { 230 | try { 231 | extract($this->viewData, EXTR_OVERWRITE); 232 | return eval('return $this->_returnViewData(' . $localizationParameters . ');'); 233 | } catch (Exception $e) { 234 | return [ 235 | $localizationParameters 236 | ]; 237 | } 238 | } 239 | 240 | /** 241 | * Find end of tag 242 | * 243 | * @param string $body 244 | * @param string $tagName 245 | * @param int $from 246 | * @return bool|int return false if end of tag is not found 247 | */ 248 | private function findEndOfTag($body, $tagName, $from) 249 | { 250 | $openTag = '<' . $tagName; 251 | $closeTag = '#i', $body, $tagMatch, null, $end)) { 265 | return $end + strlen($tagMatch[0]); 266 | } else { 267 | return false; 268 | } 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /resources/views/featherlight.php: -------------------------------------------------------------------------------- 1 | 161 | 162 | --------------------------------------------------------------------------------