Welcome
diff --git a/src/CoreServiceProvider.php b/src/CoreServiceProvider.php index f47b8db..0f84530 100644 --- a/src/CoreServiceProvider.php +++ b/src/CoreServiceProvider.php @@ -32,6 +32,7 @@ use Framework\Http\Response; use Framework\Supports\MessagesBag; use Framework\Supports\Somoy; +use Framework\View\SectionManager; use Framework\View\TemplateEngine; use Framework\View\ViewContext; @@ -57,6 +58,7 @@ public function register() $this->app->singleton(Response::class); $this->app->singleton(TemplateEngine::class); $this->app->singleton(ViewContext::class); + $this->app->singleton(SectionManager::class); if (class_exists(\Faker\Factory::class)) { $this->app->singleton(\Faker\Factory::class, function () { diff --git a/src/Routing/SiteRouter.php b/src/Routing/SiteRouter.php index decaa7b..e1b41fb 100644 --- a/src/Routing/SiteRouter.php +++ b/src/Routing/SiteRouter.php @@ -370,12 +370,18 @@ public function handle_template_include(string $template, int $priority) $resolved = $engine->resolve_path($path); if ($resolved !== '') { - app(ViewContext::class)->prepare( + $view_context = app(ViewContext::class); + $view_context->prepare( $result, (string) $route->get_name(), $resolved ); + $master_layout = $result->get_master_layout(); + if ($master_layout !== null) { + $view_context->set_active_attribute('master_layout', $master_layout); + } + // WordPress includes the returned path immediately after this // filter, which starts output. This is the last point at which // the identifier cookie can still be sent. diff --git a/src/View/SectionManager.php b/src/View/SectionManager.php new file mode 100644 index 0000000..1e0177a --- /dev/null +++ b/src/View/SectionManager.php @@ -0,0 +1,138 @@ + + * + * @since 2.2.0 + */ + protected $sections = []; + + /** + * Name of the section currently being captured, or null. + * + * @var string|null + * + * @since 2.2.0 + */ + protected $active_section = null; + + /** + * Begin capturing a named section. + * + * @param string $name Section name. + * + * @return void + * + * @throws RuntimeException When a section is already being captured. + * + * @since 2.2.0 + */ + public function start(string $name) + { + if ($this->active_section !== null) { + throw new RuntimeException( + sprintf( + 'Cannot start section [%s] while section [%s] is already being captured.', + $name, + $this->active_section + ) + ); + } + + $this->active_section = $name; + + ob_start(); + } + + /** + * End the current section capture and store the buffered content. + * + * @return void + * + * @throws RuntimeException When no section is being captured. + * + * @since 2.2.0 + */ + public function end() + { + if ($this->active_section === null) { + throw new RuntimeException('Cannot end section: no section is being captured.'); + } + + $this->sections[$this->active_section] = (string) ob_get_clean(); + $this->active_section = null; + } + + /** + * Get a section's content, or the default value if not defined. + * + * @param string $name Section name. + * @param string $default Fallback content when the section was not defined. + * + * @return string + * + * @since 2.2.0 + */ + public function get(string $name, string $default = '') + { + return $this->sections[$name] ?? $default; + } + + /** + * Check if a section has been defined. + * + * @param string $name Section name. + * + * @return bool + * + * @since 2.2.0 + */ + public function has(string $name) + { + return isset($this->sections[$name]); + } + + /** + * Clear all stored sections and reset active capture state. + * + * @return void + * + * @since 2.2.0 + */ + public function clear() + { + $this->sections = []; + $this->active_section = null; + } + + /** + * Get the name of the currently active section, or null. + * + * @return string|null + * + * @since 2.2.0 + */ + public function get_active() + { + return $this->active_section; + } +} diff --git a/src/View/TemplateEngine.php b/src/View/TemplateEngine.php index 1206318..2c6a850 100644 --- a/src/View/TemplateEngine.php +++ b/src/View/TemplateEngine.php @@ -74,15 +74,17 @@ public function get_shared() /** * Render a view template to a string. * - * @param string $view The view name in dot notation. - * @param array $data The data to pass to the view. - * @param bool $layout Whether to wrap with theme header/footer. + * @param string $view The view name in dot notation. + * @param array $data The data to pass to the view. + * @param bool|string $layout Layout mode: true for theme wrapping, + * false for no wrapping, or a master + * layout template name. * * @return string * * @since 1.0.0 */ - public function render(string $view, array $data = [], bool $layout = true) + public function render(string $view, array $data = [], $layout = true) { $path = $this->resolve_path($view); @@ -100,6 +102,10 @@ public function render(string $view, array $data = [], bool $layout = true) ]); try { + if (is_string($layout)) { + return $this->render_with_master_layout($path, $layout); + } + $content = $this->render_file($path); if (!$layout) { @@ -112,6 +118,46 @@ public function render(string $view, array $data = [], bool $layout = true) } } + /** + * Render a child template within a master layout. + * + * The child template is executed first, populating sections via + * SectionManager. Then the master layout is rendered, yielding + * those sections with render_section(). + * + * @param string $child_path Absolute path to the child template. + * @param string $master_view Master layout template name in dot notation. + * + * @return string + * + * @throws RuntimeException When the master layout cannot be resolved. + * + * @since 2.2.0 + */ + protected function render_with_master_layout(string $child_path, string $master_view) + { + $master_path = $this->resolve_path($master_view); + + if ($master_path === '') { + throw new RuntimeException(sprintf('Master layout [%s] not found.', $master_view)); + } + + $sections = app(SectionManager::class); + $sections->clear(); + + // Render the child template – its start_section() / end_section() + // calls populate the SectionManager. + $this->render_file($child_path); + + // Render the master layout, which calls render_section() to yield + // the captured sections. + $output = $this->render_file($master_path); + + $sections->clear(); + + return $output; + } + /** * Resolve a view name to an absolute file path. * diff --git a/src/View/View.php b/src/View/View.php index b5f119f..99200fe 100644 --- a/src/View/View.php +++ b/src/View/View.php @@ -33,9 +33,13 @@ class View protected $data = []; /** - * Whether to wrap the view in the theme layout. + * Layout wrapping mode for this view. * - * @var bool + * - true: Wrap with the standard theme header/footer. + * - false: No theme wrapping (partial). + * - string: Wrap with the specified custom master layout template. + * + * @var bool|string * * @since 1.0.0 */ @@ -74,15 +78,19 @@ public function partial() /** * Enable or set layout wrapping for this view. * - * @param bool $enabled Whether layout wrapping is enabled. + * Pass `true` for standard theme wrapping, `false` to disable, + * or a template name string (e.g. 'site.account.master') for + * a custom master layout. + * + * @param bool|string $layout Layout mode or master template name. * * @return $this * * @since 1.0.0 */ - public function layout($enabled = true) + public function layout($layout = true) { - $this->with_layout = (bool) $enabled; + $this->with_layout = is_string($layout) ? $layout : (bool) $layout; return $this; } @@ -130,13 +138,30 @@ public function get_data() /** * Whether the view uses layout wrapping. * + * Returns true for both standard theme layout (true) and + * custom master layout (string). Returns false only when + * layout is explicitly disabled. + * * @return bool * * @since 1.0.0 */ public function uses_layout() { - return $this->with_layout; + return $this->with_layout !== false; + } + + /** + * Get the master layout template name, if set. + * + * @return string|null Template name in dot notation, or null when + * using standard theme layout or no layout. + * + * @since 2.2.0 + */ + public function get_master_layout() + { + return is_string($this->with_layout) ? $this->with_layout : null; } /** diff --git a/src/View/ViewContext.php b/src/View/ViewContext.php index 05008ab..d070488 100644 --- a/src/View/ViewContext.php +++ b/src/View/ViewContext.php @@ -157,6 +157,25 @@ public function get_active() return $this->stack[count($this->stack) - 1]; } + /** + * Set an attribute on the topmost context frame. + * + * @param string $key Attribute key. + * @param mixed $value Attribute value. + * + * @return void + * + * @since 2.2.0 + */ + public function set_active_attribute(string $key, $value) + { + if ($this->stack === []) { + return; + } + + $this->stack[count($this->stack) - 1][$key] = $value; + } + /** * Find the innermost stack frame that authorizes the current caller. * diff --git a/src/View/layout-wrapper.php b/src/View/layout-wrapper.php index 2082119..4ac4ca4 100644 --- a/src/View/layout-wrapper.php +++ b/src/View/layout-wrapper.php @@ -1,6 +1,7 @@ resolve_path($active['master_layout']); + + if ($master_path === '') { + return; + } + + $sections = app(SectionManager::class); + $sections->clear(); + + // Execute the child template to populate sections. + ob_start(); + require $path; + ob_end_clean(); + + // Render the master layout which yields the captured sections. + ob_start(); + require $master_path; + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled layout HTML; dynamic data is escaped in view templates via esc_*. + echo (string) ob_get_clean(); + + $sections->clear(); + + return; +} + +// Standard theme layout: wrap with header/footer. ob_start(); require $path; $content = (string) ob_get_clean(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled layout HTML; dynamic data is escaped in view templates via esc_*. -echo app(TemplateEngine::class)->wrap_layout($content); +echo $engine->wrap_layout($content); diff --git a/src/helpers.php b/src/helpers.php index 203f74e..f8d5fb7 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -479,6 +479,65 @@ function include_view(string $view, array $data = []) } } +if (!function_exists('Framework\start_section')) { + /** + * Begin capturing a named section for a master layout. + * + * @param string $name Section name. + * + * @return void + * + * @since 2.2.0 + * @throws \RuntimeException When a section is already being captured. + */ + function start_section(string $name) + { + app(\Framework\View\SectionManager::class)->start($name); + } +} + +if (!function_exists('Framework\end_section')) { + /** + * End the current section capture. + * + * @return void + * + * @since 2.2.0 + * @throws \RuntimeException When no section is being captured. + */ + function end_section() + { + app(\Framework\View\SectionManager::class)->end(); + } +} + +if (!function_exists('Framework\render_section')) { + /** + * Render a named section in a master layout template. + * + * Echoes the captured section content, or the default value + * if the section was not defined by the child template. + * + * @param string $name Section name. + * @param string $default Fallback content when the section was not defined. + * + * @return void + * + * @since 2.2.0 + */ + function render_section(string $name, string $default = '') + { + $content = app(\Framework\View\SectionManager::class)->get($name, $default); + + if ($content === '') { + return; + } + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Section content is trusted template HTML; dynamic values are escaped within the templates. + echo $content; + } +} + if (!function_exists('Framework\redirect')) { /** * Create a redirect response. diff --git a/tests/Unit/View/MasterLayoutTest.php b/tests/Unit/View/MasterLayoutTest.php new file mode 100644 index 0000000..161d6f4 --- /dev/null +++ b/tests/Unit/View/MasterLayoutTest.php @@ -0,0 +1,194 @@ +views = sys_get_temp_dir() . '/framework-master-layout-' . uniqid(); + mkdir($this->views . '/site/account', 0777, true); + + $app = $this->bootstrap_application(); + $app->use_view_path($this->views); + $app->instance(TemplateEngine::class, new TemplateEngine()); + $app->instance(ViewContext::class, new ViewContext()); + $app->instance(SectionManager::class, new SectionManager()); + } + + protected function tearDown(): void + { + $this->remove_directory($this->views); + + parent::tearDown(); + } + + public function test_view_layout_accepts_string(): void + { + $view = view('shop.product', ['id' => 1]); + + $view->layout('site.account.master'); + + $this->assertTrue($view->uses_layout()); + $this->assertSame('site.account.master', $view->get_master_layout()); + } + + public function test_view_layout_true_returns_null_master(): void + { + $view = view('shop.product'); + + $view->layout(true); + + $this->assertTrue($view->uses_layout()); + $this->assertNull($view->get_master_layout()); + } + + public function test_view_layout_false_disables_layout(): void + { + $view = view('shop.product'); + + $view->layout(false); + + $this->assertFalse($view->uses_layout()); + $this->assertNull($view->get_master_layout()); + } + + public function test_partial_overrides_string_layout(): void + { + $view = view('shop.product'); + $view->layout('site.account.master'); + $view->partial(); + + $this->assertFalse($view->uses_layout()); + $this->assertNull($view->get_master_layout()); + } + + public function test_render_with_master_layout_composes_sections(): void + { + // Child template: defines title and content sections. + file_put_contents( + $this->views . '/site/account/dashboard.php', + 'Welcome
"; \Framework\end_section();' + ); + + // Master layout: yields sections. + file_put_contents( + $this->views . '/site/account/master.php', + 'Welcome