diff --git a/core/config/view.php b/core/config/view.php index 03660d4194..f14655deec 100644 --- a/core/config/view.php +++ b/core/config/view.php @@ -4,6 +4,35 @@ EVO_BASE_PATH . 'views/' ], 'compiled' => EVO_STORAGE_PATH . 'blade', + + /* + |-------------------------------------------------------------------------- + | Template file engines + |-------------------------------------------------------------------------- + | + | Which view engines the template form offers to scaffold a file for. A + | document whose template alias resolves to a file under one of the view + | paths above is rendered by that file's engine instead of by the parser, + | and Laravel's view factory already resolves any extension registered with + | it - so this list is not what makes an engine work, only what the manager + | is willing to create a file for. Engines opt in: an extension nobody + | declared here stays out of the UI, and one declared without its engine + | actually being registered is dropped rather than offered. + | + | 'processor' names the [(chunk_processor)] value an engine belongs to, and + | only decides which entry the form preselects. A plugin adds its own from + | its service provider: + | + | config(['view.template_engines' => array_merge( + | config('view.template_engines', []), + | ['latte' => ['label' => 'Latte', 'processor' => 'aLatteX']] + | )]); + | + */ + 'template_engines' => [ + 'blade.php' => ['label' => 'Blade', 'processor' => null], + 'php' => ['label' => 'PHP', 'processor' => null], + ], 'directive' => [ //---------- /** diff --git a/core/database/migrations/2026_08_27_000000_add_templatefileextension_to_site_templates.php b/core/database/migrations/2026_08_27_000000_add_templatefileextension_to_site_templates.php new file mode 100644 index 0000000000..c6c7058482 --- /dev/null +++ b/core/database/migrations/2026_08_27_000000_add_templatefileextension_to_site_templates.php @@ -0,0 +1,37 @@ +string('templatefileextension', 20)->default('')->after('templatealias'); + }); + } + + public function down() { + if (Schema::hasTable('site_templates') && Schema::hasColumn('site_templates', 'templatefileextension')) { + Schema::table('site_templates', function (Blueprint $table) { + $table->dropColumn('templatefileextension'); + }); + } + } +} diff --git a/core/database/migrations/2026_08_27_000100_add_templatesource_to_site_templates.php b/core/database/migrations/2026_08_27_000100_add_templatesource_to_site_templates.php new file mode 100644 index 0000000000..5324bb748f --- /dev/null +++ b/core/database/migrations/2026_08_27_000100_add_templatesource_to_site_templates.php @@ -0,0 +1,36 @@ +string('templatesource', 10)->default('')->after('templatealias'); + }); + } + + public function down() { + if (Schema::hasTable('site_templates') && Schema::hasColumn('site_templates', 'templatesource')) { + Schema::table('site_templates', function (Blueprint $table) { + $table->dropColumn('templatesource'); + }); + } + } +} diff --git a/core/src/Controllers/Template.php b/core/src/Controllers/Template.php index 8ade58f887..d450d4290a 100644 --- a/core/src/Controllers/Template.php +++ b/core/src/Controllers/Template.php @@ -2,6 +2,8 @@ use EvolutionCMS\Models; use EvolutionCMS\Interfaces\ManagerTheme; +use EvolutionCMS\Support\TemplateFileEngines; +use EvolutionCMS\TemplateProcessor; use Illuminate\Support\Collection; use Illuminate\Database\Eloquent; @@ -9,6 +11,13 @@ class Template extends AbstractController implements ManagerTheme\PageController { protected $view = 'page.template'; + /** + * A template file larger than this is not handed to the editor for + * switching. Templates are not this big; something else is going on, and a + * megabyte of it does not belong inline in the form. + */ + private const MAX_EDITABLE_FILE_BYTES = 524288; + protected $events = [ 'OnTempFormPrerender', 'OnTempFormRender' @@ -51,6 +60,48 @@ public function canView(): bool public function process() : bool { $this->object = $this->parameterData(); + $engines = TemplateFileEngines::make(); + $existingFiles = $engines->existing((string) $this->object->templatealias); + $winningExtension = $engines->winner( + (string) $this->object->templatealias, + (string) $this->object->templatefileextension + ); + + // A template that reads from a file shows the file in the editor, not + // the database column - editing what is not rendered would be a trap. + // The database copy stays where it is as the fallback it already is. + // "Automatic" only describes templates that predate the setting. A + // template being created now has no old behaviour to preserve, so it + // starts where its code is being typed. + $templateSource = (string) $this->object->templatesource; + if ($templateSource === '' && !$this->object->getKey()) { + $templateSource = TemplateProcessor::SOURCE_DATABASE; + } + + // The editor has to be able to show whatever the selectors point at, the + // moment they are moved - otherwise it keeps displaying code that the + // save is not going to write, which is the one thing an editor must + // never do. The database column and every file already on disk are + // handed to the page so the swap needs no round trip. + $databaseContent = (string) $this->object->content; + $fileContents = []; + foreach ($existingFiles as $extension => $path) { + if (is_readable($path) && filesize($path) <= self::MAX_EDITABLE_FILE_BYTES) { + $fileContents[$extension] = (string) file_get_contents($path); + } + } + + $sourceFile = null; + if ($templateSource === TemplateProcessor::SOURCE_FILE) { + $sourceFile = $engines->pathFor( + (string) $this->object->templatealias, + (string) $this->object->templatefileextension + ); + $pinned = ltrim((string) $this->object->templatefileextension, '.'); + if ($sourceFile !== null && isset($fileContents[$pinned])) { + $this->object->content = $fileContents[$pinned]; + } + } $this->parameters = [ 'data' => $this->object, 'categories' => $this->parameterCategories(), @@ -69,7 +120,24 @@ function (Models\SiteTmplvar $item) { ), 'action' => $this->getIndex(), 'events' => $this->parameterEvents(), - 'actionButtons' => $this->parameterActionButtons() + 'actionButtons' => $this->parameterActionButtons(), + 'templateFileEngines' => $engines->all(), + // A new template follows [(chunk_processor)]; one that already + // recorded an engine keeps showing it, so that opening a template + // and saving it cannot quietly re-point it at another engine. + 'templateFileDefault' => $engines->isRegistered($this->object->templatefileextension) + ? ltrim((string) $this->object->templatefileextension, '.') + : $engines->defaultExtension( + (string) $this->managerTheme->getCore()->getConfig('chunk_processor') + ), + // Only one file can render an alias; the rest are shadowed, so the + // form says which ones are already there and which of them wins. + 'templateFileExisting' => $existingFiles, + 'templateFileWinner' => $winningExtension, + 'templateSource' => $templateSource, + 'templateSourceFile' => $sourceFile, + 'templateDbContent' => $databaseContent, + 'templateFileContents' => $fileContents ]; return true; diff --git a/core/src/Core.php b/core/src/Core.php index b5feb885d3..a05408126b 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -84,6 +84,23 @@ class Core extends AbstractLaravel implements Interfaces\CoreInterface public $documentMethod; public $documentGenerated; public $documentContent; + + /** + * The view the current document is rendered from, or '' when it is rendered + * from template code by the parser. + * + * A template whose alias resolves to a file under a view path is handed to + * Laravel's view factory, and parseDocumentSource() is then skipped - so by + * the time OnLoadWebDocument fires, documentContent holds finished HTML + * from another engine rather than template source. A listener that + * transforms template code has to be able to tell the two apart, and + * calling TemplateProcessor::getBladeDocumentContent() again to find out is + * not free: it can run a controller's main() a second time. + * + * @var string + * @since 3.5.9 + */ + public $documentTemplateView = ''; public $documentOutput; public $tstart = 0; public $mstart = 0; @@ -3300,6 +3317,7 @@ public function prepareResponse() } $template = TemplateProcessor::getBladeDocumentContent(); + $this->documentTemplateView = $template ? (string) $template : ''; if ($template) { $this->documentObject['cacheable'] = 0; @@ -3326,7 +3344,15 @@ public function prepareResponse() app('DLTemplate')->blade->share(array_merge($data, $viewData)); } - $tpl = $this['view']->make($template, $viewData); + // A template that pinned an engine names the exact file, so the + // view factory is handed a path rather than a name - resolving + // a name would put the question back to extension registration + // order and hand the document to whichever engine booted last. + $viewPath = TemplateProcessor::getDocumentViewPath(); + + $tpl = $viewPath !== '' + ? $this['view']->file($viewPath, $viewData) + : $this['view']->make($template, $viewData); $templateCode = $tpl->render(); } else { // get the template and start parsing! diff --git a/core/src/Models/SiteTemplate.php b/core/src/Models/SiteTemplate.php index b4436511f6..2dfdc7eccc 100644 --- a/core/src/Models/SiteTemplate.php +++ b/core/src/Models/SiteTemplate.php @@ -8,6 +8,9 @@ * * @property int $id * @property string $templatename + * @property string $templatealias + * @property string $templatesource + * @property string $templatefileextension * @property string $description * @property int $editor_type * @property int $category @@ -60,6 +63,8 @@ class SiteTemplate extends Eloquent\Model protected $fillable = [ 'templatename', 'templatealias', + 'templatesource', + 'templatefileextension', 'description', 'editor_type', 'category', diff --git a/core/src/Support/TemplateFileEngines.php b/core/src/Support/TemplateFileEngines.php new file mode 100644 index 0000000000..02d0d8b91f --- /dev/null +++ b/core/src/Support/TemplateFileEngines.php @@ -0,0 +1,267 @@ + */ + private array $engines; + + /** @var string[] */ + private array $viewPaths; + + /** + * @param array $declared extension => ['label' => ..., 'processor' => ...] + * @param string[]|null $renderable extensions the view factory knows, in resolution + * order; null means "cannot ask", and every + * declaration is taken at face value + * @param string[] $viewPaths + */ + public function __construct(array $declared, ?array $renderable, array $viewPaths) + { + $normalised = []; + foreach ($declared as $extension => $engine) { + $extension = ltrim((string) $extension, '.'); + if ($extension === '') { + continue; + } + + $normalised[$extension] = [ + 'label' => (string) (is_array($engine) ? ($engine['label'] ?? $extension) : $extension), + 'processor' => is_array($engine) && isset($engine['processor']) && $engine['processor'] !== '' + ? (string) $engine['processor'] + : null, + ]; + } + + if ($renderable === null) { + $this->engines = $normalised; + } else { + // An engine declared but never registered would scaffold a file + // nothing renders, so it is not offered. Ordering follows the + // factory, which is what decides who wins when two files share an + // alias. + $ordered = []; + foreach ($renderable as $extension) { + $extension = ltrim((string) $extension, '.'); + if (isset($normalised[$extension])) { + $ordered[$extension] = $normalised[$extension]; + } + } + $this->engines = $ordered; + } + + $this->viewPaths = $viewPaths === [] ? [EVO_BASE_PATH . 'views/'] : array_values($viewPaths); + } + + /** + * Build from the running application. + */ + public static function make(): self + { + $declared = []; + $viewPaths = []; + if (function_exists('config')) { + try { + $declared = (array) config('view.template_engines', []); + $viewPaths = (array) config('view.paths', []); + } catch (\Throwable) { + $declared = []; + $viewPaths = []; + } + } + + return new self($declared, static::renderableExtensions(), $viewPaths); + } + + /** + * @return array + */ + public function all(): array + { + return $this->engines; + } + + /** + * The extension the form preselects: the one belonging to the active chunk + * processor, or else the first offered. + */ + public function defaultExtension(?string $chunkProcessor = null): ?string + { + if ($this->engines === []) { + return null; + } + + $chunkProcessor = (string) $chunkProcessor; + if ($chunkProcessor !== '') { + foreach ($this->engines as $extension => $engine) { + if ($engine['processor'] !== null + && mb_strtolower($engine['processor']) === mb_strtolower($chunkProcessor)) { + return $extension; + } + } + } + + // Falling through to whatever happens to be first would preselect an + // engine that belongs to a different chunk processor - a Latte file for + // a DLTemplate site - so a general purpose engine is preferred. + foreach ($this->engines as $extension => $engine) { + if ($engine['processor'] === null) { + return $extension; + } + } + + return (string) array_key_first($this->engines); + } + + /** + * Whether an extension may be written. Everything that reaches the file + * system goes through here: the form posts an extension, and an extension + * is half of a filename in the web root. + */ + public function isRegistered(?string $extension): bool + { + return $extension !== null && isset($this->engines[ltrim($extension, '.')]); + } + + /** + * The file an alias would be scaffolded to, or null when the alias cannot + * safely be one - the same rule the form applies while typing. + */ + public function filename(string $alias, string $extension): ?string + { + if (!$this->isRegistered($extension)) { + return null; + } + + $name = preg_replace('/\s*/', '', $alias); + $name = preg_replace('/[^a-zA-Z0-9_-]+/', '', (string) $name); + + if ($name === '' || $name !== $alias) { + return null; + } + + return $name . '.' . ltrim($extension, '.'); + } + + /** + * Files that already exist for an alias, in resolution order - the first is + * the one that renders, the rest are shadowed. + * + * @return array extension => absolute path + */ + public function existing(string $alias): array + { + $found = []; + foreach (array_keys($this->engines) as $extension) { + $filename = $this->filename($alias, $extension); + if ($filename === null) { + continue; + } + + foreach ($this->viewPaths as $path) { + $candidate = rtrim((string) $path, "/\\") . '/' . $filename; + if (is_file($candidate)) { + $found[$extension] = $candidate; + break; + } + } + } + + return $found; + } + + /** + * The path of one engine's file for an alias, if it is there. + */ + public function pathFor(string $alias, ?string $extension): ?string + { + if ($extension === null || $extension === '') { + return null; + } + + $filename = $this->filename($alias, ltrim($extension, '.')); + if ($filename === null) { + return null; + } + + foreach ($this->viewPaths as $path) { + $candidate = rtrim((string) $path, "/\\") . '/' . $filename; + if (is_file($candidate)) { + return $candidate; + } + } + + return null; + } + + /** + * The extension that actually renders an alias: the one the template pinned + * when it was saved, or - with nothing pinned, or the pinned file gone - + * whichever the view factory would reach first. '' when no file exists. + */ + public function winner(string $alias, ?string $pinned = null): string + { + $pinned = $pinned === null ? '' : ltrim($pinned, '.'); + if ($pinned !== '' && $this->pathFor($alias, $pinned) !== null) { + return $pinned; + } + + $existing = $this->existing($alias); + + return $existing === [] ? '' : (string) array_key_first($existing); + } + + /** + * The directories views are looked for in. + * + * @return string[] + */ + public function viewPaths(): array + { + return $this->viewPaths; + } + + /** + * The directory a scaffolded file is written to. + */ + public function scaffoldPath(): string + { + return rtrim((string) ($this->viewPaths[0] ?? (EVO_BASE_PATH . 'views/')), "/\\"); + } + + /** + * Extensions the view factory knows, most significant first, or null when + * there is no factory to ask (console tooling, tests). + * + * @return string[]|null + */ + protected static function renderableExtensions(): ?array + { + if (!function_exists('app')) { + return null; + } + + try { + $factory = app('view'); + } catch (\Throwable) { + return null; + } + + if (!is_object($factory) || !method_exists($factory, 'getExtensions')) { + return null; + } + + return array_keys($factory->getExtensions()); + } +} diff --git a/core/src/TemplateProcessor.php b/core/src/TemplateProcessor.php index 7e96542a8b..b60c0650e3 100644 --- a/core/src/TemplateProcessor.php +++ b/core/src/TemplateProcessor.php @@ -1,10 +1,17 @@ core = $core; } + /** + * Absolute path of the file the current document must render from, when its + * template names one, or '' otherwise. + * + * Resolving by view name asks the view factory, which tries extensions in + * registration order - so the last engine to boot would decide for every + * template on the site. A template that recorded an engine when it was + * saved gets that file instead, and only falls back to the factory when the + * file it named is gone. + * + * @var string + */ + protected $documentViewPath = ''; + + public function getDocumentViewPath(): string + { + return $this->documentViewPath; + } + public function getBladeDocumentContent() { + $this->documentViewPath = ''; $template = false; $doc = $this->core->documentObject; if(isset($this->core->documentObject['templatealias']) && $this->core->documentObject['templatealias'] != ''){ @@ -34,6 +61,21 @@ public function getBladeDocumentContent() } } + // "Database" is an answer, not a starting point: no view path is walked, + // no extension is tried, and a file that happens to share the alias has + // no say. It is also the cheapest branch on the page - the lookups + // below are a filesystem probe per extension per view path. + if ($this->templateSource($doc) === self::SOURCE_DATABASE) { + return false; + } + + $pinned = $this->pinnedTemplateFile($doc, $templateAlias); + if ($pinned !== '') { + $this->documentViewPath = $pinned; + + return $templateAlias; + } + switch (true) { case $this->core['view']->exists('tpl-' . $doc['template'] . '_doc-' . $doc['id']): $template = 'tpl-' . $doc['template'] . '_doc-' . $doc['id']; @@ -84,12 +126,68 @@ function ($item) { if (!$this->core['view']->exists($template)) { $this->core->documentObject['template'] = 0; $this->core->documentContent = $doc['content']; + // Returning the name of a view that is not there sends + // the caller to $view->make() anyway, which throws: + // "View [x] not found", a 500 where this branch was + // written to degrade instead. + $template = false; } } } return $template; } + /** + * Where the current document's template says its code lives. + * + * '' is every template that predates the setting, and means "decide the old + * way": a matching file wins if one happens to exist. + */ + private function templateSource(array $doc): string + { + $templateId = (int) get_by_key($doc, 'template', 0); + if ($templateId === 0) { + return ''; + } + + return (string) SiteTemplate::whereKey($templateId)->value('templatesource'); + } + + /** + * The file a template pinned to an engine when it was saved, if that file is + * still there. + * + * The document specific views (tpl-N_doc-M, doc-M, tpl-N) are deliberately + * not overridden: those are per document overrides of the template, and a + * template pinning its own engine says nothing about them. + */ + private function pinnedTemplateFile(array $doc, string $templateAlias): string + { + if ($templateAlias === '') { + return ''; + } + + foreach (['tpl-' . get_by_key($doc, 'template') . '_doc-' . get_by_key($doc, 'id'), + 'doc-' . get_by_key($doc, 'id'), + 'tpl-' . get_by_key($doc, 'template')] as $override) { + if ($this->core['view']->exists($override)) { + return ''; + } + } + + $templateId = (int) get_by_key($doc, 'template', 0); + if ($templateId === 0) { + return ''; + } + + $extension = (string) SiteTemplate::whereKey($templateId)->value('templatefileextension'); + if ($extension === '') { + return ''; + } + + return (string) (TemplateFileEngines::make()->pathFor($templateAlias, $extension) ?? ''); + } + /** * @param $templateID * @return mixed diff --git a/core/tests/Unit/Manager/TemplateFileEnginesTest.php b/core/tests/Unit/Manager/TemplateFileEnginesTest.php new file mode 100644 index 0000000000..afd77c862a --- /dev/null +++ b/core/tests/Unit/Manager/TemplateFileEnginesTest.php @@ -0,0 +1,214 @@ + ['label' => 'Blade', 'processor' => null], + 'php' => ['label' => 'PHP', 'processor' => null], + 'latte' => ['label' => 'Latte', 'processor' => 'aLatteX'], + ], + $renderable, + $viewPaths + ); +} + +it('offers every declared engine the view factory can render', function () { + expect(array_keys(templateFileEngines()->all())) + ->toBe(['blade.php', 'php', 'latte']); +}); + +it('drops a declaration whose extension nothing can render', function () { + // A .latte file is only a template once an engine is registered for it; + // until then, offering it would scaffold a file nothing reads. + $engines = templateFileEngines(['blade.php', 'php']); + + expect(array_keys($engines->all()))->toBe(['blade.php', 'php']) + ->and($engines->isRegistered('latte'))->toBeFalse(); +}); + +it('orders engines the way the view factory resolves them', function () { + // addExtension() prepends, so the last engine registered wins an alias. + $engines = templateFileEngines(['latte', 'blade.php', 'php']); + + expect(array_key_first($engines->all()))->toBe('latte'); +}); + +it('preselects the engine belonging to the active chunk processor', function () { + $engines = templateFileEngines(); + + expect($engines->defaultExtension('aLatteX'))->toBe('latte') + ->and($engines->defaultExtension('DLTemplate'))->toBe('blade.php') + ->and($engines->defaultExtension(''))->toBe('blade.php') + ->and($engines->defaultExtension(null))->toBe('blade.php'); +}); + +it('does not preselect an engine that belongs to another processor', function () { + // A plugin registering its engine puts it first in the factory's order, and + // that must not turn it into the default for everybody else. + $engines = templateFileEngines(['latte', 'blade.php', 'php']); + + expect(array_key_first($engines->all()))->toBe('latte') + ->and($engines->defaultExtension('DLTemplate'))->toBe('blade.php') + ->and($engines->defaultExtension('aLatteX'))->toBe('latte'); +}); + +it('falls back to the first engine when every one is claimed', function () { + $engines = new TemplateFileEngines( + ['latte' => ['label' => 'Latte', 'processor' => 'aLatteX']], + null, + ['/views/'] + ); + + expect($engines->defaultExtension('DLTemplate'))->toBe('latte'); +}); + +it('has nothing to offer when no engine is declared', function () { + $engines = new TemplateFileEngines([], null, ['/views/']); + + expect($engines->all())->toBe([]) + ->and($engines->defaultExtension('aLatteX'))->toBeNull() + ->and($engines->filename('alone', 'blade.php'))->toBeNull(); +}); + +it('accepts only registered extensions', function () { + $engines = templateFileEngines(); + + expect($engines->isRegistered('latte'))->toBeTrue() + ->and($engines->isRegistered('.latte'))->toBeTrue() + ->and($engines->isRegistered('phtml'))->toBeFalse() + ->and($engines->isRegistered(null))->toBeFalse(); +}); + +it('refuses to build a filename from an unregistered extension', function (string $extension) { + expect(templateFileEngines()->filename('alone', $extension))->toBeNull(); +})->with([ + 'unknown engine' => ['phtml'], + 'traversal' => ['./../index'], + 'trailing dot' => ['latte.php.'], + 'empty' => [''], +]); + +it('refuses an alias that is not already a safe filename', function (string $alias) { + expect(templateFileEngines()->filename($alias, 'latte'))->toBeNull(); +})->with([ + 'traversal' => ['../alone'], + 'slash' => ['views/alone'], + 'space' => ['al one'], + 'dot' => ['alone.latte'], + 'empty' => [''], +]); + +it('builds the filename for a safe alias', function () { + $engines = templateFileEngines(); + + expect($engines->filename('alone', 'latte'))->toBe('alone.latte') + ->and($engines->filename('alone', '.blade.php'))->toBe('alone.blade.php') + ->and($engines->filename('my_page-2', 'php'))->toBe('my_page-2.php'); +}); + +it('reports the files already on disk, first one wins', function () { + $dir = sys_get_temp_dir() . '/evo-template-files-' . bin2hex(random_bytes(4)); + mkdir($dir); + + try { + file_put_contents($dir . '/alone.blade.php', ''); + file_put_contents($dir . '/alone.latte', ''); + + $engines = templateFileEngines(['latte', 'blade.php', 'php'], [$dir]); + $existing = $engines->existing('alone'); + + expect(array_keys($existing))->toBe(['latte', 'blade.php']) + ->and($existing['latte'])->toBe($dir . '/alone.latte') + ->and($engines->existing('nothinghere'))->toBe([]); + } finally { + array_map('unlink', glob($dir . '/*') ?: []); + rmdir($dir); + } +}); + +/** + * Which file renders is the template's own decision, recorded when it was + * saved. Without that the answer comes from the view factory's extension order, + * which the last plugin to boot gets to set for the whole site. + */ +it('renders the engine the template pinned, whatever registered first', function () { + $dir = sys_get_temp_dir() . '/evo-template-pin-' . bin2hex(random_bytes(4)); + mkdir($dir); + + try { + foreach (['alone.latte', 'alone.blade.php', 'alone.php'] as $file) { + file_put_contents($dir . '/' . $file, ''); + } + + // latte first: what the factory would pick on its own. + $engines = templateFileEngines(['latte', 'blade.php', 'php'], [$dir]); + + expect($engines->winner('alone', 'blade.php'))->toBe('blade.php') + ->and($engines->winner('alone', 'php'))->toBe('php') + ->and($engines->winner('alone', 'latte'))->toBe('latte') + ->and($engines->pathFor('alone', 'blade.php'))->toBe($dir . '/alone.blade.php'); + } finally { + array_map('unlink', glob($dir . '/*') ?: []); + rmdir($dir); + } +}); + +it('falls back to the factory order when nothing is pinned', function () { + $dir = sys_get_temp_dir() . '/evo-template-pin-' . bin2hex(random_bytes(4)); + mkdir($dir); + + try { + file_put_contents($dir . '/alone.latte', ''); + file_put_contents($dir . '/alone.blade.php', ''); + + $engines = templateFileEngines(['latte', 'blade.php', 'php'], [$dir]); + + expect($engines->winner('alone', ''))->toBe('latte') + ->and($engines->winner('alone', null))->toBe('latte'); + } finally { + array_map('unlink', glob($dir . '/*') ?: []); + rmdir($dir); + } +}); + +it('falls back when the pinned file has been deleted', function () { + $dir = sys_get_temp_dir() . '/evo-template-pin-' . bin2hex(random_bytes(4)); + mkdir($dir); + + try { + file_put_contents($dir . '/alone.latte', ''); + + $engines = templateFileEngines(['latte', 'blade.php', 'php'], [$dir]); + + // Pinned to Blade, but somebody removed the Blade file. + expect($engines->pathFor('alone', 'blade.php'))->toBeNull() + ->and($engines->winner('alone', 'blade.php'))->toBe('latte'); + } finally { + array_map('unlink', glob($dir . '/*') ?: []); + rmdir($dir); + } +}); + +it('reports no winner when the alias has no file at all', function () { + $dir = sys_get_temp_dir() . '/evo-template-pin-' . bin2hex(random_bytes(4)); + mkdir($dir); + + try { + $engines = templateFileEngines(null, [$dir]); + + expect($engines->winner('alone', 'blade.php'))->toBe('') + ->and($engines->pathFor('alone', 'blade.php'))->toBeNull() + ->and($engines->pathFor('../evil', 'blade.php'))->toBeNull() + ->and($engines->pathFor('alone', 'phtml'))->toBeNull(); + } finally { + rmdir($dir); + } +}); diff --git a/core/tests/Unit/SystemTasks/ConsoleInstallFlowServiceTest.php b/core/tests/Unit/SystemTasks/ConsoleInstallFlowServiceTest.php index 91550f30bc..bfb0bcd83a 100644 --- a/core/tests/Unit/SystemTasks/ConsoleInstallFlowServiceTest.php +++ b/core/tests/Unit/SystemTasks/ConsoleInstallFlowServiceTest.php @@ -29,8 +29,13 @@ function invokeConsoleInstallFlowMethod(ConsoleInstallFlowService $service, stri ], ]); + // EVO_CORE_PATH is built from __DIR__, which is backslashed on Windows, so + // the path arrives as "C:\...\core/artisan". Which separator the host uses + // is not what this test is about - Symfony's Process takes either. + $artisanPath = str_replace('\\', '/', $arguments[1]); + expect($arguments[0])->toBe(PHP_BINARY) - ->and($arguments[1])->toContain('/core/artisan') + ->and($artisanPath)->toEndWith('/core/artisan') ->and($arguments[2])->toBe('package:installrequire') ->and($arguments)->toContain('evolution-cms/ecodemirror') ->and($arguments)->toContain('dev-main') diff --git a/core/vendor/composer/autoload_classmap.php b/core/vendor/composer/autoload_classmap.php index 91683a5dec..3f6eeefb02 100644 --- a/core/vendor/composer/autoload_classmap.php +++ b/core/vendor/composer/autoload_classmap.php @@ -7,6 +7,8 @@ return array( 'AddCachepwdExpiryToUsers' => $baseDir . '/database/migrations/2026_08_20_000000_add_cachepwd_expiry_to_users.php', + 'AddTemplatefileextensionToSiteTemplates' => $baseDir . '/database/migrations/2026_08_27_000000_add_templatefileextension_to_site_templates.php', + 'AddTemplatesourceToSiteTemplates' => $baseDir . '/database/migrations/2026_08_27_000100_add_templatesource_to_site_templates.php', 'BladeUI\\Icons\\BladeIconsServiceProvider' => $vendorDir . '/blade-ui-kit/blade-icons/src/BladeIconsServiceProvider.php', 'BladeUI\\Icons\\Components\\Icon' => $vendorDir . '/blade-ui-kit/blade-icons/src/Components/Icon.php', 'BladeUI\\Icons\\Components\\Svg' => $vendorDir . '/blade-ui-kit/blade-icons/src/Components/Svg.php', @@ -1407,6 +1409,7 @@ 'EvolutionCMS\\Support\\SiteTimezone' => $baseDir . '/src/Support/SiteTimezone.php', 'EvolutionCMS\\Support\\SqliteDumper' => $baseDir . '/src/Support/SqliteDumper.php', 'EvolutionCMS\\Support\\SystemSettingPathNormalizer' => $baseDir . '/src/Support/SystemSettingPathNormalizer.php', + 'EvolutionCMS\\Support\\TemplateFileEngines' => $baseDir . '/src/Support/TemplateFileEngines.php', 'EvolutionCMS\\TemplateProcessor' => $baseDir . '/src/TemplateProcessor.php', 'EvolutionCMS\\Tracy\\Debugger' => $baseDir . '/src/Tracy/Debugger.php', 'EvolutionCMS\\Tracy\\Panels\\AbstractPanel' => $baseDir . '/src/Tracy/Panels/AbstractPanel.php', diff --git a/core/vendor/composer/autoload_static.php b/core/vendor/composer/autoload_static.php index 16b376b8b1..7e5476db4b 100644 --- a/core/vendor/composer/autoload_static.php +++ b/core/vendor/composer/autoload_static.php @@ -684,6 +684,8 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 public static $classMap = array ( 'AddCachepwdExpiryToUsers' => __DIR__ . '/../..' . '/database/migrations/2026_08_20_000000_add_cachepwd_expiry_to_users.php', + 'AddTemplatefileextensionToSiteTemplates' => __DIR__ . '/../..' . '/database/migrations/2026_08_27_000000_add_templatefileextension_to_site_templates.php', + 'AddTemplatesourceToSiteTemplates' => __DIR__ . '/../..' . '/database/migrations/2026_08_27_000100_add_templatesource_to_site_templates.php', 'BladeUI\\Icons\\BladeIconsServiceProvider' => __DIR__ . '/..' . '/blade-ui-kit/blade-icons/src/BladeIconsServiceProvider.php', 'BladeUI\\Icons\\Components\\Icon' => __DIR__ . '/..' . '/blade-ui-kit/blade-icons/src/Components/Icon.php', 'BladeUI\\Icons\\Components\\Svg' => __DIR__ . '/..' . '/blade-ui-kit/blade-icons/src/Components/Svg.php', @@ -2084,6 +2086,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Support\\SiteTimezone' => __DIR__ . '/../..' . '/src/Support/SiteTimezone.php', 'EvolutionCMS\\Support\\SqliteDumper' => __DIR__ . '/../..' . '/src/Support/SqliteDumper.php', 'EvolutionCMS\\Support\\SystemSettingPathNormalizer' => __DIR__ . '/../..' . '/src/Support/SystemSettingPathNormalizer.php', + 'EvolutionCMS\\Support\\TemplateFileEngines' => __DIR__ . '/../..' . '/src/Support/TemplateFileEngines.php', 'EvolutionCMS\\TemplateProcessor' => __DIR__ . '/../..' . '/src/TemplateProcessor.php', 'EvolutionCMS\\Tracy\\Debugger' => __DIR__ . '/../..' . '/src/Tracy/Debugger.php', 'EvolutionCMS\\Tracy\\Panels\\AbstractPanel' => __DIR__ . '/../..' . '/src/Tracy/Panels/AbstractPanel.php', diff --git a/install/stubs/migrations/2026_08_27_000000_add_templatefileextension_to_site_templates.php b/install/stubs/migrations/2026_08_27_000000_add_templatefileextension_to_site_templates.php new file mode 100644 index 0000000000..c6c7058482 --- /dev/null +++ b/install/stubs/migrations/2026_08_27_000000_add_templatefileextension_to_site_templates.php @@ -0,0 +1,37 @@ +string('templatefileextension', 20)->default('')->after('templatealias'); + }); + } + + public function down() { + if (Schema::hasTable('site_templates') && Schema::hasColumn('site_templates', 'templatefileextension')) { + Schema::table('site_templates', function (Blueprint $table) { + $table->dropColumn('templatefileextension'); + }); + } + } +} diff --git a/install/stubs/migrations/2026_08_27_000100_add_templatesource_to_site_templates.php b/install/stubs/migrations/2026_08_27_000100_add_templatesource_to_site_templates.php new file mode 100644 index 0000000000..5324bb748f --- /dev/null +++ b/install/stubs/migrations/2026_08_27_000100_add_templatesource_to_site_templates.php @@ -0,0 +1,36 @@ +string('templatesource', 10)->default('')->after('templatealias'); + }); + } + + public function down() { + if (Schema::hasTable('site_templates') && Schema::hasColumn('site_templates', 'templatesource')) { + Schema::table('site_templates', function (Blueprint $table) { + $table->dropColumn('templatesource'); + }); + } + } +} diff --git a/manager/actions/mutate_menuindex_sort.dynamic.php b/manager/actions/mutate_menuindex_sort.dynamic.php index 1a1c13aa16..4ceeb237e8 100755 --- a/manager/actions/mutate_menuindex_sort.dynamic.php +++ b/manager/actions/mutate_menuindex_sort.dynamic.php @@ -10,7 +10,7 @@ $id = isset($_REQUEST['id']) ? (int) $_REQUEST['id'] : null; $reset = isset($_POST['reset']) && $_POST['reset'] == 'true' ? 1 : 0; $items = isset($_POST['list']) ? $_POST['list'] : ''; -$ressourcelist = ''; +$resourcelist = ''; $updateMsg = ''; // check permissions on the document @@ -39,7 +39,7 @@ $disabled = 'true'; $pagetitle = ''; -$ressourcelist = ''; +$resourcelist = ''; if ($id !== null) { if ($id > 0) { try { @@ -74,16 +74,16 @@ } if ($resources->count() > 0) { - $ressourcelist .= '
    '; + $resourcelist .= '
      '; foreach ($resources->get()->toArray() as $row) { $classes = ''; $classes .= ($row['hidemenu']) ? ' notInMenuNode ' : ' inMenuNode'; $classes .= ($row['published']) ? ' publishedNode ' : ' unpublishedNode '; $classes = ($row['deleted']) ? ' deletedNode ' : $classes; $icon = $row['isfolder'] ? ' ' : ' '; - $ressourcelist .= '
    • ' . $icon . $row['pagetitle'] . ' (' . $row['id'] . ')
    • '; + $resourcelist .= '
    • ' . $icon . $row['pagetitle'] . ' (' . $row['id'] . ')
    • '; } - $ressourcelist .= '
    '; + $resourcelist .= '
'; } else { $updateMsg = '

' . $_lang['sort_nochildren'] . '

'; } @@ -172,7 +172,7 @@ function resetSortOrder() {
getPhpCompat()->entities($pagetitle) ?> ()

@@ -183,7 +183,7 @@ class=""> - + defaultExtension(evo()->getConfig('chunk_processor')); + if ($extension === null) { + return; + } + + $filename = $engines->filename((string) $templatealias, (string) $extension); + if ($filename === null) { + return; + } + + $views = $engines->scaffoldPath(); + + if (!file_exists($views . '/' . $filename)) { + if (!is_dir($views)) { + mkdir($views, 0777, true); + } + + if (is_writeable($views)) { + file_put_contents($views . '/' . $filename, ''); + } + } +} + +/** + * @deprecated since 3.5.9, use createTemplateFile() + * @todo [remove@3.7] + */ function createBladeFile($templatealias) { - $filename = $templatealias; - $filename = preg_replace('/\s*/', '', $filename); - $filename = preg_replace('/[^a-zA-Z0-9_-]+/', '', $filename); + createTemplateFile($templatealias, 'blade.php'); +} - if (!empty($filename) && $filename == $templatealias) { - $filename .= '.blade.php'; - $views = EVO_BASE_PATH . 'views'; +/** + * The engine this template renders with, as picked in the form. + * + * Stored on the template so that the file it names is the one that renders, + * rather than whichever extension a plugin registered last. An empty string + * means the form offered no choice (an older theme, or a third party posting + * here), and the template keeps whatever it had. + */ +function selectedTemplateFileExtension() +{ + $extension = get_by_key($_POST, 'templatefileextension'); - if (!file_exists($views . '/' . $filename)) { - if (!is_dir($views)) { - mkdir($views); - } + if (\EvolutionCMS\Support\TemplateFileEngines::make()->isRegistered($extension)) { + return (string) $extension; + } - if (is_writeable($views)) { - file_put_contents($views . '/' . $filename, ''); - } + // Older themes post only the blade checkbox, which is a choice too. + return !empty($_POST['createbladefile']) ? 'blade.php' : ''; +} + +/** + * Whether an older theme asked for a file the way it used to be asked for: a + * checkbox next to a database-backed template. The current form says it by + * choosing where the code lives instead. + */ +function wantsTemplateFileCreated() +{ + return !empty($_POST['createtemplatefile']) || !empty($_POST['createbladefile']); +} + +/** + * Where the form says this template's code lives, or '' when it did not say - + * an older theme, or anything else posting here, which must not be read as a + * decision to move a template's code. + */ +function selectedTemplateSource() +{ + $source = (string) get_by_key($_POST, 'templatesource', ''); + + return in_array($source, [ + \EvolutionCMS\TemplateProcessor::SOURCE_DATABASE, + \EvolutionCMS\TemplateProcessor::SOURCE_FILE, + ], true) ? $source : ''; +} + +/** + * Stop rather than lose the edit. + * + * A template that keeps its code in a file does not write that code to the + * column as well, so a file that cannot be written means the editor's contents + * have nowhere to go. The form values are put back in the session first, which + * is how this processor already handles a rejected save. + */ +function templateFileWriteFailed($templatealias, $extension, $action, $id = null) +{ + global $_lang; + + EvolutionCMS()->getManagerApi()->saveFormValues($action); + EvolutionCMS()->webAlertAndQuit( + sprintf( + get_by_key($_lang, 'template_file_not_writable', 'The template file %s could not be written, so nothing was saved. Check the alias and the permissions of the views directory.'), + $templatealias . '.' . ltrim((string) $extension, '.') + ), + 'index.php?a=' . $action . ($id !== null ? '&id=' . $id : '') + ); +} + +/** + * Write the editor's contents to the template's file. + * + * The path is never taken from the request: it is rebuilt from the alias and + * an extension the registry recognises, so nothing outside a configured view + * path and nothing with an extension no engine renders can be written. + * + * @return bool whether the file now holds the posted content + */ +function writeTemplateFile($templatealias, $extension, $content, $mayCreate) +{ + $engines = \EvolutionCMS\Support\TemplateFileEngines::make(); + $path = $engines->pathFor((string) $templatealias, (string) $extension); + + if ($path === null) { + if (!$mayCreate) { + return false; + } + + createTemplateFile($templatealias, $extension); + $path = $engines->pathFor((string) $templatealias, (string) $extension); + + if ($path === null) { + return false; } } + + return is_writable($path) && file_put_contents($path, (string) $content) !== false; } switch ($_POST['mode']) { @@ -82,11 +199,34 @@ function createBladeFile($templatealias) EvolutionCMS()->webAlertAndQuit(sprintf($_lang["duplicate_template_alias_found"], $docid, $templatealias), "index.php?a=19"); } //do stuff to save the new doc + $source = selectedTemplateSource(); + $extension = selectedTemplateFileExtension(); + $goesToFile = $source === \EvolutionCMS\TemplateProcessor::SOURCE_FILE; + + // The file goes first. It is the only copy of the code in this mode, so + // a row that survives a failed write would point at a file that never + // arrived - and claim the save succeeded. + if ($goesToFile) { + // A file already sitting at this name belongs to whoever put it + // there - it is adopted, not overwritten by a template that has + // only just been named. + $alreadyOnDisk = \EvolutionCMS\Support\TemplateFileEngines::make() + ->pathFor($templatealias, $extension) !== null; + + if (!$alreadyOnDisk && !writeTemplateFile($templatealias, $extension, $template, true)) { + templateFileWriteFailed($templatealias, $extension, 19); + } + } + + // Code destined for a file is written to the file, not to the column - + // one copy, in the place that renders. $newid = \EvolutionCMS\Models\SiteTemplate::query()->insertGetId([ 'templatename' => $templatename, 'templatealias' => $templatealias, + 'templatesource' => $source, + 'templatefileextension' => $extension, 'description' => $description, - 'content' => $template, + 'content' => $goesToFile ? '' : $template, 'locked' => $locked, 'selectable' => $selectable, 'category' => $categoryid, @@ -102,8 +242,8 @@ function createBladeFile($templatealias) // Set new assigned Tvs saveTemplateAccess($newid); - if (!empty($_POST['createbladefile'])) { - createBladeFile($templatealias); + if (!$goesToFile && wantsTemplateFileCreated()) { + createTemplateFile($templatealias, $extension); } // Set the item name for logger @@ -149,7 +289,7 @@ function createBladeFile($templatealias) EvolutionCMS()->webAlertAndQuit(sprintf($_lang["duplicate_template_alias_found"], $docid, $templatealias), "index.php?a=16&id={$id}"); } //do stuff to save the edited doc - \EvolutionCMS\Models\SiteTemplate::find($id)->update([ + $updates = [ 'templatename' => $templatename, 'templatealias' => $templatealias, 'description' => $description, @@ -158,12 +298,62 @@ function createBladeFile($templatealias) 'selectable' => $selectable, 'category' => $categoryid, 'editedon' => $currentdate - ]); + ]; + + // An older theme, or anything else posting here without the engine + // picker, must not silently re-point an existing template at a + // different engine - so the column is only written when a choice was + // actually made. + $selectedExtension = selectedTemplateFileExtension(); + if ($selectedExtension !== '') { + $updates['templatefileextension'] = $selectedExtension; + } + + $source = selectedTemplateSource(); + if ($source !== '') { + $updates['templatesource'] = $source; + } + + // The editor's contents go wherever this template says its code lives, + // and nowhere else. The form loads the file behind whichever pair of + // selectors is chosen, so what is posted is always what was on screen - + // including the database copy, once the source is switched back to it. + $goesToFile = $source === \EvolutionCMS\TemplateProcessor::SOURCE_FILE; + $saved = \EvolutionCMS\Models\SiteTemplate::whereKey($id) + ->first(['templatesource', 'content']); + $wasFile = (string) ($saved->templatesource ?? '') + === \EvolutionCMS\TemplateProcessor::SOURCE_FILE; + + if ($goesToFile) { + unset($updates['content']); + } + + // What the editor is showing is what gets written - the form loads the + // file behind whichever pair of selectors is chosen, so the two cannot + // disagree. Unless the form could not do that: with scripting off the + // editor still holds the database copy when the source is switched to + // a file, and writing it would flatten a file nobody has looked at. + // Recognisable precisely, because the posted code is the column, + // character for character. + $stalePost = $goesToFile + && !$wasFile + && (string) $template === (string) ($saved->content ?? '') + && \EvolutionCMS\Support\TemplateFileEngines::make() + ->pathFor($templatealias, $selectedExtension) !== null; + + // Same order as a new template: nothing about the template changes + // until its code is safely on disk. + if ($goesToFile && !$stalePost + && !writeTemplateFile($templatealias, $selectedExtension, $template, true)) { + templateFileWriteFailed($templatealias, $selectedExtension, 16, $id); + } + + \EvolutionCMS\Models\SiteTemplate::find($id)->update($updates); // Set new assigned Tvs saveTemplateAccess($id); - if (!empty($_POST['createbladefile'])) { - createBladeFile($templatealias); + if (!$goesToFile && wantsTemplateFileCreated()) { + createTemplateFile($templatealias, $selectedExtension); } // invoke OnTempFormSave event diff --git a/manager/views/page/system_settings.blade.php b/manager/views/page/system_settings.blade.php index 05b3b81c86..4b7f8e1f1c 100644 --- a/manager/views/page/system_settings.blade.php +++ b/manager/views/page/system_settings.blade.php @@ -90,8 +90,10 @@ function setChangesChunkProcessor(item) { item = item || document.querySelector('[name="chunk_processor"]:checked'); + // Any template processor from plugin is not among options and we still sure it is not DLTemplate + var lockFilters = !!item && item.checked && item.value === 'DLTemplate'; document.querySelectorAll('[name="enable_at_syntax"], [name="enable_filter"]').forEach(function(el) { - if (item.checked && item.value === 'DLTemplate') { + if (lockFilters) { el.checked = !!el.value; el.disabled = true; } else { diff --git a/manager/views/page/template.blade.php b/manager/views/page/template.blade.php index e26ccad9cd..1433cb690c 100644 --- a/manager/views/page/template.blade.php +++ b/manager/views/page/template.blade.php @@ -35,35 +35,252 @@ document.querySelector('.element-edit-message').classList.toggle('show'); }; - var checkContainer = document.getElementById('assigned-blade-file'), - filenameLabel = document.getElementById('blade-filename'), + var checkContainer = document.getElementById('assigned-template-file'), + filenameLabel = document.getElementById('template-filename'), alias = document.getElementById('templatealias'), - check = document.getElementById('createbladefile'); - - var updateFilename = function(value) { - var filename = value; - filename = filename.replace(/\s*/g, ''); - filename = filename.replace(/[^a-zA-Z0-9_-]+/g, ''); + templatename = document.getElementsByName('templatename')[0], + extension = document.getElementById('templatefileextension'); + + // The engine list can be empty, in which case the block is not + // rendered at all and there is nothing to keep up to date. + if (checkContainer && filenameLabel && alias) { + var note = document.getElementById('template-file-note'), + source = document.getElementById('templatesource'), + savedAlias = checkContainer.dataset.alias || '', + savedSource = checkContainer.dataset.source || '', + savedExtension = checkContainer.dataset.extension || '', + winner = checkContainer.dataset.winner || '', + existing = []; + + try { + existing = JSON.parse(checkContainer.dataset.existing || '[]'); + } catch (e) { + existing = []; + } - if (filename == value && filename != '') { - filenameLabel.innerText = '/views/' + filename + '.blade.php'; + // What is on disk was read for the alias as saved. Rename + // the alias in the form and it says nothing about the new + // one, so the warnings go quiet rather than lie. + var noteFor = function(selected) { + // Moving the code out of the database and into a file + // leaves the database copy behind untouched, and coming + // back later shows that copy rather than the file. + if (source && source.value !== savedSource) { + if (source.value === 'db' && savedSource === 'file') { + return {{ Illuminate\Support\Js::from(ManagerTheme::getLexicon('template_source_back_to_db', 'The editor now shows the database copy, and that is what will be saved. The file is left where it is.')) }}; + } + if (source.value === 'file' + && existing.indexOf(extension ? extension.value : '') === -1) { + return {{ Illuminate\Support\Js::from(ManagerTheme::getLexicon('template_source_to_file', 'What is in the editor is written to the file on save. The database copy is kept as it is.')) }}; + } + } + + if (alias.value !== savedAlias || !existing.length) { + return ''; + } + + // Picking an engine whose file exists loads that file + // into the editor, so there is nothing to warn about: + // what is on screen is what will be written back. + if (existing.indexOf(selected) !== -1) { + return ''; + } + + // Until this template is saved with the new engine, + // the file that renders is still the one it is pinned + // to - or, with nothing pinned, whichever the view + // factory finds first. + if (!winner) { + return ''; + } + + return {{ Illuminate\Support\Js::from(ManagerTheme::getLexicon('template_file_shadowed', 'Until this template is saved, this file still renders:')) }} + + ' ' + savedAlias + '.' + winner; + }; + + // What the editor must show for a given pair of selectors: + // the database column, or the file that pair points at. A + // pair with no file yet keeps whatever is on screen - that + // is the code being moved into it. + var dbContent = {{ Illuminate\Support\Js::from($templateDbContent) }}, + fileContents = {{ Illuminate\Support\Js::from($templateFileContents) }}, + shownKey = savedSource === 'file' && savedExtension !== '' + ? 'file:' + savedExtension + : 'db'; + + var editorValue = function() { + if (window.myCodeMirrors && window.myCodeMirrors['post']) { + return window.myCodeMirrors['post'].getValue(); + } + + var box = document.getElementsByName('post')[0]; + + return box ? box.value : ''; + }; + + var setEditorValue = function(value) { + var box = document.getElementsByName('post')[0]; + + if (box) { + box.value = value; + } + + if (window.myCodeMirrors && window.myCodeMirrors['post']) { + window.myCodeMirrors['post'].setValue(value); + } + }; + + var lastLoaded = editorValue(); + + // Switching away from unsaved edits would drop them + // silently, so it is asked about rather than assumed. + var mayReplaceEditor = function() { + if (editorValue() === lastLoaded) { + return true; + } + + return window.confirm( + {{ Illuminate\Support\Js::from(ManagerTheme::getLexicon('template_source_discard_edits', 'The editor has unsaved changes. Switching loads the other copy and discards them. Continue?')) }} + ); + }; + + var syncEditor = function() { + var onFile = source && source.value === 'file', + selected = extension ? extension.value : '', + key = onFile ? 'file:' + selected : 'db'; + + if (key === shownKey) { + return; + } + + // No file there yet: the editor's contents are what + // will be written into it, so they stay put. + if (onFile && !Object.prototype.hasOwnProperty.call(fileContents, selected)) { + shownKey = key; + return; + } + + if (!mayReplaceEditor()) { + // Put the selectors back where they were. + if (shownKey === 'db') { + if (source) { source.value = 'db'; } + } else if (source) { + source.value = 'file'; + if (extension) { extension.value = shownKey.slice(5); } + } + return; + } + + setEditorValue(onFile ? fileContents[selected] : dbContent); + lastLoaded = editorValue(); + shownKey = key; + }; + + // The editor is shared with the database view, where the + // code is EVO template markup; a file gets the highlighting + // of whatever engine reads it. The plugin publishes its + // instances on window, so no plugin change is needed - and + // if it is switched off, this simply does nothing. + var modes = { + 'php': 'application/x-httpd-php', + 'css': 'text/css' + }; + + var applyHighlighting = function() { + if (!window.myCodeMirrors || !window.myCodeMirrors['post']) { + return; + } + + var onFile = source && source.value === 'file', + mode = onFile && extension + ? (modes[extension.value] || 'htmlmixed') + : 'htmlmixed'; + + try { + window.myCodeMirrors['post'].setOption('mode', mode); + } catch (e) { + // An editor that will not take a mode is not worth + // breaking the form over. + } + }; + + // The file is named after the alias, and the alias is + // filled in from the name when it is left blank - so a + // template being created has a filename to show before its + // alias field has anything in it. + var previewName = function() { + var value = alias.value !== '' + ? alias.value + : (templatename ? templatename.value : ''); + + return value + .replace(/\s*/g, '') + .replace(/[^a-zA-Z0-9_-]+/g, '') + .toLowerCase(); + }; + + var updateFilename = function() { + var onFile = source + ? (source.value === 'file' || (source.value === '' && existing.length)) + : true; + + // The engine and the filename only mean anything for a + // template that reads from a file. + if (!onFile) { + checkContainer.style.display = 'none'; + if (note) { + var switching = noteFor(extension ? extension.value : ''); + note.innerText = switching; + note.style.display = switching ? 'block' : 'none'; + } + return; + } + + // Choosing a file and being told nothing about which + // file is the state this block exists to prevent, so it + // stays visible even when the name is not usable yet. checkContainer.style.display = 'block'; - check.disabled = false; - } else { - checkContainer.style.display = 'none'; - check.disabled = true; - } - }; - alias.addEventListener('change', function(event) { - updateFilename(this.value); - }); + var filename = previewName(), + selected = extension ? extension.value : 'blade.php'; + + filenameLabel.innerText = filename !== '' + ? '/views/' + filename + '.' + selected + : {{ Illuminate\Support\Js::from(ManagerTheme::getLexicon('template_file_pending', 'named after the alias, once there is one')) }}; + + if (note) { + var message = noteFor(selected); + note.innerText = message; + note.style.display = message ? 'block' : 'none'; + } + + applyHighlighting(); + }; + + var onSelectorChange = function() { + syncEditor(); + updateFilename(); + }; + + alias.addEventListener('change', updateFilename); + alias.addEventListener('input', updateFilename); + if (templatename) { + templatename.addEventListener('input', updateFilename); + } + if (source) { + source.addEventListener('change', onSelectorChange); + } + if (extension) { + extension.addEventListener('change', onSelectorChange); + } - alias.addEventListener('input', function(event) { - updateFilename(this.value); - }); + updateFilename(); - updateFilename(alias.value); + // The editor is created by a plugin whose script may not + // have run yet. + applyHighlighting(); + window.setTimeout(applyHighlighting, 0); + } }); @@ -168,23 +385,62 @@

-