This commit is contained in:
Sven Steinert
2026-05-13 12:19:42 +02:00
parent f4511b9213
commit 3ff9146a63
13 changed files with 499 additions and 76 deletions

View File

@@ -15,6 +15,114 @@
padding: 24px 20px 48px;
}
.kb-docs-app {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 32px;
align-items: start;
}
.kb-app-sidebar {
position: sticky;
top: 24px;
max-height: calc(100vh - 48px);
overflow: auto;
border: 1px solid var(--kb-border);
border-radius: 8px;
padding: 16px;
background: var(--kb-surface);
}
.kb-app-sidebar__brand {
margin-bottom: 14px;
font-size: 18px;
font-weight: 700;
}
.kb-app-sidebar__brand a {
color: var(--kb-text);
text-decoration: none;
}
.kb-app-sidebar__search {
margin-bottom: 16px;
}
.kb-app-sidebar__search .kb-search {
padding: 0;
border: 0;
box-shadow: none;
background: transparent;
}
.kb-app-sidebar__search .kb-search h2,
.kb-app-sidebar__search .kb-search-results {
display: none;
}
.kb-app-nav,
.kb-app-nav ul {
margin: 0;
padding: 0;
list-style: none;
}
.kb-app-product {
padding: 10px 0;
border-top: 1px solid var(--kb-border);
}
.kb-app-product:first-child {
border-top: 0;
}
.kb-app-product__link,
.kb-app-version-list a,
.kb-app-page-list a {
display: block;
border-radius: 6px;
color: var(--kb-text);
text-decoration: none;
}
.kb-app-product__link {
padding: 8px 10px;
font-weight: 700;
}
.kb-app-version-list a {
padding: 6px 10px 6px 22px;
color: var(--kb-muted);
font-size: 14px;
}
.kb-app-page-list a {
padding: 5px 10px 5px 36px;
color: var(--kb-muted);
font-size: 13px;
line-height: 1.35;
}
.kb-app-product__link:hover,
.kb-app-version-list a:hover,
.kb-app-page-list a:hover,
.kb-app-product.is-active > .kb-app-product__link,
.kb-app-version-list li.is-active > a,
.kb-app-page-list li.is-active > a {
background: var(--kb-accent-soft);
color: var(--kb-accent);
}
.kb-app-main {
min-width: 0;
}
.kb-docs-home > h1,
.kb-docs-product > h1,
.kb-docs-version > h1 {
margin-top: 0;
}
.kb-product-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
@@ -195,6 +303,15 @@
}
@media (max-width: 780px) {
.kb-docs-app {
grid-template-columns: 1fr;
}
.kb-app-sidebar {
position: static;
max-height: none;
}
.kb-doc-layout {
grid-template-columns: 1fr;
}

View File

@@ -68,12 +68,38 @@
}
.kb-product-card,
.kb-search {
.kb-search,
.kb-app-sidebar {
border-radius: var(--kb-radius);
border-color: var(--kb-border);
box-shadow: var(--kb-shadow);
}
.kb-app-sidebar {
background: rgba(255, 255, 255, 0.96);
}
.kb-app-sidebar__brand a {
font-family: var(--kb-font-strong);
color: var(--kb-text);
}
.kb-app-product__link,
.kb-app-version-list a,
.kb-app-page-list a {
border-radius: 11px;
}
.kb-app-product__link:hover,
.kb-app-version-list a:hover,
.kb-app-page-list a:hover,
.kb-app-product.is-active > .kb-app-product__link,
.kb-app-version-list li.is-active > a,
.kb-app-page-list li.is-active > a {
background: rgba(0, 167, 230, 0.12);
color: var(--kb-primary-shade);
}
.kb-product-card h2 a {
color: var(--kb-text);
text-decoration: none;

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace KbMarkdownImporter\Admin;
use KbMarkdownImporter\Repository\ProductRepository;
final class ProductsPage
{
public static function render(): void
{
if (! current_user_can('manage_kb_docs')) {
wp_die(esc_html__('You do not have permission to manage documentation products.', 'kb-markdown-importer'));
}
$repository = new ProductRepository();
self::handleActions($repository);
$products = $repository->allWithStats();
?>
<div class="wrap">
<h1><?php esc_html_e('Documentation Products', 'kb-markdown-importer'); ?></h1>
<p><?php esc_html_e('Manage imported products, repair their URL slugs, or remove a broken import from the frontend.', 'kb-markdown-importer'); ?></p>
<?php settings_errors('kb_markdown_products'); ?>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e('Product', 'kb-markdown-importer'); ?></th>
<th><?php esc_html_e('Slug', 'kb-markdown-importer'); ?></th>
<th><?php esc_html_e('Versions', 'kb-markdown-importer'); ?></th>
<th><?php esc_html_e('Pages', 'kb-markdown-importer'); ?></th>
<th><?php esc_html_e('Actions', 'kb-markdown-importer'); ?></th>
</tr>
</thead>
<tbody>
<?php if (! $products) : ?>
<tr><td colspan="5"><?php esc_html_e('No products have been imported yet.', 'kb-markdown-importer'); ?></td></tr>
<?php endif; ?>
<?php foreach ($products as $item) : ?>
<?php
$term = $item['term'];
$formId = 'kb-markdown-product-' . (int) $term->term_id;
$versions = array_map(static fn (\WP_Term $version): string => $version->name, (array) $item['versions']);
?>
<tr>
<td>
<input form="<?php echo esc_attr($formId); ?>" class="regular-text" type="text" name="product_name" value="<?php echo esc_attr($term->name); ?>" aria-label="<?php esc_attr_e('Product name', 'kb-markdown-importer'); ?>">
</td>
<td>
<input form="<?php echo esc_attr($formId); ?>" class="regular-text" type="text" name="product_slug" value="<?php echo esc_attr($term->slug); ?>" aria-label="<?php esc_attr_e('Product slug', 'kb-markdown-importer'); ?>">
</td>
<td><?php echo esc_html($versions ? implode(', ', $versions) : __('No versions', 'kb-markdown-importer')); ?></td>
<td><?php echo esc_html((string) $item['page_count']); ?></td>
<td>
<form id="<?php echo esc_attr($formId); ?>" method="post" action="">
<?php wp_nonce_field('kb_markdown_update_product_' . $term->term_id); ?>
<input type="hidden" name="kb_markdown_product_action" value="update">
<input type="hidden" name="term_id" value="<?php echo esc_attr((string) $term->term_id); ?>">
<?php submit_button(__('Save', 'kb-markdown-importer'), 'secondary small', 'submit', false); ?>
</form>
<form method="post" action="" style="margin-top:8px;" onsubmit="return window.confirm('<?php echo esc_js(__('Move this product and its imported pages to the trash?', 'kb-markdown-importer')); ?>');">
<?php wp_nonce_field('kb_markdown_delete_product_' . $term->term_id); ?>
<input type="hidden" name="kb_markdown_product_action" value="delete">
<input type="hidden" name="term_id" value="<?php echo esc_attr((string) $term->term_id); ?>">
<label>
<input type="checkbox" name="trash_pages" value="1" checked>
<?php esc_html_e('Trash imported pages', 'kb-markdown-importer'); ?>
</label>
<?php submit_button(__('Delete product', 'kb-markdown-importer'), 'delete small', 'submit', false); ?>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
}
private static function handleActions(ProductRepository $repository): void
{
$action = sanitize_key(wp_unslash((string) ($_POST['kb_markdown_product_action'] ?? '')));
if (! $action) {
return;
}
$termId = absint($_POST['term_id'] ?? 0);
if (! $termId) {
add_settings_error('kb_markdown_products', 'missing_term', __('Missing product ID.', 'kb-markdown-importer'), 'error');
return;
}
if ('update' === $action) {
check_admin_referer('kb_markdown_update_product_' . $termId);
$result = $repository->update(
$termId,
sanitize_text_field(wp_unslash((string) ($_POST['product_name'] ?? ''))),
sanitize_title(wp_unslash((string) ($_POST['product_slug'] ?? '')))
);
if (is_wp_error($result)) {
add_settings_error('kb_markdown_products', 'update_failed', $result->get_error_message(), 'error');
return;
}
add_settings_error('kb_markdown_products', 'updated', __('Product saved.', 'kb-markdown-importer'), 'success');
return;
}
if ('delete' === $action) {
check_admin_referer('kb_markdown_delete_product_' . $termId);
$trashPages = ! empty($_POST['trash_pages']);
$result = $repository->deleteProduct($termId, $trashPages);
if (is_wp_error($result)) {
add_settings_error('kb_markdown_products', 'delete_failed', $result->get_error_message(), 'error');
return;
}
add_settings_error('kb_markdown_products', 'deleted', __('Product deleted.', 'kb-markdown-importer'), 'success');
}
}
}

View File

@@ -143,11 +143,7 @@ final class Router
public static function shortcodeDocsIndex(): string
{
return (new TemplateLoader())->capture('documentation-index', [
'products' => self::productsWithVersions(),
'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'),
'url_builder' => UrlBuilder::class,
]);
return (new self())->captureRoute('index');
}
public static function shortcodeDocsApp(array $atts = []): string
@@ -188,12 +184,12 @@ final class Router
$atts = shortcode_atts(['product' => ''], $atts, 'kb_product_index');
$router = new self();
return $router->captureProduct((string) $atts['product']);
return $router->captureRoute('product', sanitize_title((string) $atts['product']));
}
private function renderIndex(): void
{
echo $this->captureIndex();
echo $this->captureRoute('index');
}
private function captureIndex(): string
@@ -207,7 +203,7 @@ final class Router
private function renderProduct(string $productSlug): void
{
echo $this->captureProduct($productSlug);
echo $this->captureRoute('product', $productSlug);
}
private function captureProduct(string $productSlug): string
@@ -229,7 +225,7 @@ final class Router
private function renderVersion(string $productSlug, string $versionSlug): void
{
echo $this->captureVersion($productSlug, $versionSlug);
echo $this->captureRoute('version', $productSlug, $versionSlug);
}
private function captureVersion(string $productSlug, string $versionSlug): string
@@ -257,7 +253,7 @@ final class Router
private function renderPage(string $productSlug, string $versionSlug, string $pageSlug): void
{
echo $this->capturePage($productSlug, $versionSlug, $pageSlug);
echo $this->captureRoute('page', $productSlug, $versionSlug, $pageSlug);
}
private function capturePage(string $productSlug, string $versionSlug, string $pageSlug): string
@@ -301,13 +297,35 @@ final class Router
private function captureRoute(string $route, string $productSlug = '', string $versionSlug = '', string $pageSlug = ''): string
{
return match ($route) {
$content = match ($route) {
'index' => $this->captureIndex(),
'product' => $productSlug ? $this->captureProduct($productSlug) : $this->captureIndex(),
'version' => ($productSlug && $versionSlug) ? $this->captureVersion($productSlug, $versionSlug) : $this->captureIndex(),
'page' => ($productSlug && $versionSlug) ? $this->capturePage($productSlug, $versionSlug, $pageSlug) : $this->captureIndex(),
default => $this->captureIndex(),
};
return $this->captureShell($content, $productSlug, $versionSlug, $pageSlug);
}
private function captureShell(string $content, string $productSlug = '', string $versionSlug = '', string $pageSlug = ''): string
{
$activePages = [];
if ($productSlug && $versionSlug) {
$activePages = $this->pagesForVersion($productSlug, $versionSlug);
}
return (new TemplateLoader())->capture('docs-app', [
'content' => $content,
'products' => self::productsWithVersions(),
'active_product_slug' => $productSlug,
'active_version_slug' => $versionSlug,
'active_page_slug' => $pageSlug,
'active_pages' => $activePages,
'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'),
'url_builder' => UrlBuilder::class,
]);
}
private function render404(): void

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace KbMarkdownImporter;
use KbMarkdownImporter\Admin\SettingsPage;
use KbMarkdownImporter\Admin\ProductsPage;
use KbMarkdownImporter\Admin\StatusPage;
use KbMarkdownImporter\Admin\SyncPage;
use KbMarkdownImporter\Frontend\Router;
@@ -119,6 +120,7 @@ final class Plugin
);
add_submenu_page('kb-markdown-importer', __('Overview', 'kb-markdown-importer'), __('Overview', 'kb-markdown-importer'), 'manage_kb_docs', 'kb-markdown-importer', [StatusPage::class, 'render']);
add_submenu_page('kb-markdown-importer', __('Products', 'kb-markdown-importer'), __('Products', 'kb-markdown-importer'), 'manage_kb_docs', 'kb-markdown-products', [ProductsPage::class, 'render']);
add_submenu_page('kb-markdown-importer', __('Synchronization', 'kb-markdown-importer'), __('Synchronization', 'kb-markdown-importer'), 'sync_kb_docs', 'kb-markdown-sync', [SyncPage::class, 'render']);
add_submenu_page('kb-markdown-importer', __('Settings', 'kb-markdown-importer'), __('Settings', 'kb-markdown-importer'), 'manage_kb_docs', 'kb-markdown-settings', [SettingsPage::class, 'render']);
}

View File

@@ -16,4 +16,117 @@ final class ProductRepository
return is_wp_error($term) ? 0 : (int) ($term['term_id'] ?? $term);
}
public function allWithStats(): array
{
$terms = get_terms([
'taxonomy' => 'kb_product',
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
]);
if (is_wp_error($terms)) {
return [];
}
$items = [];
foreach ($terms as $term) {
$pageIds = $this->pageIdsForProduct((int) $term->term_id);
$versions = [];
foreach ($pageIds as $pageId) {
$pageVersions = wp_get_object_terms($pageId, 'kb_version');
if (is_wp_error($pageVersions)) {
continue;
}
foreach ($pageVersions as $version) {
$versions[$version->slug] = $version;
}
}
uasort($versions, static fn ($a, $b): int => strnatcasecmp($b->name, $a->name));
$items[] = [
'term' => $term,
'page_count' => count($pageIds),
'versions' => array_values($versions),
];
}
return $items;
}
public function update(int $termId, string $name, string $slug): \WP_Term|\WP_Error
{
$name = trim($name);
$slug = sanitize_title($slug ?: $name);
if ('' === $name || '' === $slug) {
return new \WP_Error('kb_product_invalid', __('Product name and slug are required.', 'kb-markdown-importer'));
}
$updated = wp_update_term($termId, 'kb_product', [
'name' => $name,
'slug' => $slug,
]);
if (is_wp_error($updated)) {
return $updated;
}
foreach ($this->pageIdsForProduct($termId) as $pageId) {
update_post_meta($pageId, '_kb_product_slug', $slug);
}
$term = get_term((int) $updated['term_id'], 'kb_product');
return $term instanceof \WP_Term ? $term : new \WP_Error('kb_product_missing', __('Product could not be loaded after update.', 'kb-markdown-importer'));
}
public function trashProductPages(int $termId): int
{
$count = 0;
foreach ($this->pageIdsForProduct($termId, ['publish', 'draft', 'private', 'pending', 'future']) as $pageId) {
if (wp_trash_post($pageId)) {
++$count;
}
}
return $count;
}
public function deleteProduct(int $termId, bool $trashPages): true|\WP_Error
{
if ($trashPages) {
$this->trashProductPages($termId);
}
$deleted = wp_delete_term($termId, 'kb_product');
if (is_wp_error($deleted)) {
return $deleted;
}
return true;
}
private function pageIdsForProduct(int $termId, array $postStatus = ['publish']): array
{
$query = new \WP_Query([
'post_type' => 'kb_doc_page',
'post_status' => $postStatus,
'posts_per_page' => -1,
'fields' => 'ids',
'tax_query' => [
['taxonomy' => 'kb_product', 'field' => 'term_id', 'terms' => $termId],
],
]);
return array_map('intval', $query->posts);
}
}

View File

@@ -0,0 +1,58 @@
<?php
defined('ABSPATH') || exit;
$active_product_slug = (string) ($active_product_slug ?? '');
$active_version_slug = (string) ($active_version_slug ?? '');
$active_page_slug = (string) ($active_page_slug ?? '');
?>
<div class="kb-docs-wrap kb-docs-app">
<aside class="kb-app-sidebar" aria-label="<?php esc_attr_e('Documentation navigation', 'kb-markdown-importer'); ?>">
<div class="kb-app-sidebar__brand">
<a href="<?php echo esc_url($url_builder::docsIndex()); ?>"><?php esc_html_e('Knowledgebase', 'kb-markdown-importer'); ?></a>
</div>
<div class="kb-app-sidebar__search">
<?php echo do_shortcode('[kb_search]'); ?>
</div>
<nav class="kb-app-nav">
<?php foreach ((array) $products as $item) : ?>
<?php
$term = $item['term'];
$versions = (array) $item['versions'];
$latest = $versions[0] ?? null;
$isActiveProduct = $term->slug === $active_product_slug;
?>
<section class="kb-app-product <?php echo $isActiveProduct ? 'is-active' : ''; ?>">
<a class="kb-app-product__link" href="<?php echo esc_url($latest ? $url_builder::version($term->slug, $latest->slug) : $url_builder::product($term->slug)); ?>">
<?php echo esc_html($term->name); ?>
</a>
<?php if ($versions) : ?>
<ul class="kb-app-version-list">
<?php foreach ($versions as $version) : ?>
<?php $isActiveVersion = $isActiveProduct && $version->slug === $active_version_slug; ?>
<li class="<?php echo $isActiveVersion ? 'is-active' : ''; ?>">
<a href="<?php echo esc_url($url_builder::version($term->slug, $version->slug)); ?>"><?php echo esc_html($version->name); ?></a>
<?php if ($isActiveVersion && ! empty($active_pages)) : ?>
<ul class="kb-app-page-list">
<?php foreach ((array) $active_pages as $page) : ?>
<?php
$pageSlug = (string) get_post_meta($page->ID, '_kb_page_slug', true);
$isActivePage = $pageSlug === $active_page_slug || ('' === $active_page_slug && in_array($pageSlug, ['', 'index'], true));
?>
<li class="<?php echo $isActivePage ? 'is-active' : ''; ?>">
<a href="<?php echo esc_url($url_builder::page($term->slug, $version->slug, $pageSlug)); ?>"><?php echo esc_html(get_the_title($page)); ?></a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>
<?php endforeach; ?>
</nav>
</aside>
<section class="kb-app-main">
<?php echo $content; ?>
</section>
</div>

View File

@@ -1,14 +1,14 @@
<?php
defined('ABSPATH') || exit;
?>
<main class="kb-docs-wrap">
<section class="kb-docs-home">
<h1><?php esc_html_e('Knowledgebase', 'kb-markdown-importer'); ?></h1>
<?php echo do_shortcode('[kb_search]'); ?>
<div class="kb-product-list">
<?php foreach ((array) $products as $item) : ?>
<?php $term = $item['term']; ?>
<?php $latest = $item['versions'][0] ?? null; ?>
<section class="kb-product-card">
<h2><a href="<?php echo esc_url($url_builder::product($term->slug)); ?>"><?php echo esc_html($term->name); ?></a></h2>
<h2><a href="<?php echo esc_url($latest ? $url_builder::version($term->slug, $latest->slug) : $url_builder::product($term->slug)); ?>"><?php echo esc_html($term->name); ?></a></h2>
<?php if (! empty($item['versions'])) : ?>
<ul>
<?php foreach ($item['versions'] as $version) : ?>
@@ -19,4 +19,4 @@ defined('ABSPATH') || exit;
</section>
<?php endforeach; ?>
</div>
</main>
</section>

View File

@@ -1,59 +1,17 @@
<?php
defined('ABSPATH') || exit;
$render_nav = static function (array $nodes) use (&$render_nav, $base_slug, $product_slug, $version_slug, $url_builder): void {
if (! $nodes) {
return;
}
echo '<ul>';
foreach ($nodes as $node) {
$target = (string) ($node['target'] ?? '');
$label = (string) ($node['title'] ?? '');
$href = '';
if ($target) {
$slug = preg_replace('/\.md(#.+)?$/', '', basename($target)) ?: basename($target);
$slug = in_array(strtolower($slug), ['doku', 'index'], true) ? '' : sanitize_title($slug);
$href = $url_builder::page($product_slug, $version_slug, $slug);
}
echo '<li>';
if ($href) {
printf('<a href="%s">%s</a>', esc_url($href), esc_html($label));
} else {
echo '<span>' . esc_html($label) . '</span>';
}
$render_nav((array) ($node['children'] ?? []));
echo '</li>';
}
echo '</ul>';
};
?>
<main class="kb-docs-wrap kb-doc-layout">
<aside class="kb-sidebar">
<label class="screen-reader-text" for="kb-version-switcher"><?php esc_html_e('Version', 'kb-markdown-importer'); ?></label>
<select id="kb-version-switcher">
<?php foreach ((array) $versions as $item) : ?>
<option value="<?php echo esc_attr($item->slug); ?>" data-url="<?php echo esc_url($url_builder::version($product_slug, $item->slug)); ?>" <?php selected($item->slug, $version_slug); ?>><?php echo esc_html($item->name); ?></option>
<?php endforeach; ?>
</select>
<nav class="kb-sidebar-nav" aria-label="<?php esc_attr_e('Documentation navigation', 'kb-markdown-importer'); ?>">
<?php $render_nav((array) $nav_tree); ?>
</nav>
</aside>
<article class="kb-doc-content">
<nav class="kb-breadcrumbs">
<a href="<?php echo esc_url($url_builder::docsIndex()); ?>"><?php esc_html_e('Docs', 'kb-markdown-importer'); ?></a><span>/</span>
<a href="<?php echo esc_url($url_builder::product($product_slug)); ?>"><?php echo esc_html($product ? $product->name : $product_slug); ?></a><span>/</span>
<a href="<?php echo esc_url($url_builder::version($product_slug, $version_slug)); ?>"><?php echo esc_html($version ? $version->name : $version_slug); ?></a>
</nav>
<h1><?php echo esc_html(get_the_title($post)); ?></h1>
<div class="kb-rendered-content">
<?php
$rendered_content = $url_builder::rewriteHtml(apply_filters('the_content', $post->post_content));
echo wp_kses_post($rendered_content);
?>
</div>
</article>
</main>
<article class="kb-doc-content">
<nav class="kb-breadcrumbs">
<a href="<?php echo esc_url($url_builder::docsIndex()); ?>"><?php esc_html_e('Docs', 'kb-markdown-importer'); ?></a><span>/</span>
<a href="<?php echo esc_url($url_builder::product($product_slug)); ?>"><?php echo esc_html($product ? $product->name : $product_slug); ?></a><span>/</span>
<a href="<?php echo esc_url($url_builder::version($product_slug, $version_slug)); ?>"><?php echo esc_html($version ? $version->name : $version_slug); ?></a>
</nav>
<h1><?php echo esc_html(get_the_title($post)); ?></h1>
<div class="kb-rendered-content">
<?php
$rendered_content = $url_builder::rewriteHtml(apply_filters('the_content', $post->post_content));
echo wp_kses_post($rendered_content);
?>
</div>
</article>

View File

@@ -1,7 +1,7 @@
<?php
defined('ABSPATH') || exit;
?>
<main class="kb-docs-wrap">
<section class="kb-docs-product">
<nav class="kb-breadcrumbs"><a href="<?php echo esc_url($url_builder::docsIndex()); ?>"><?php esc_html_e('Docs', 'kb-markdown-importer'); ?></a><span>/</span><?php echo esc_html($product->name); ?></nav>
<h1><?php echo esc_html($product->name); ?></h1>
<h2><?php esc_html_e('Available Versions', 'kb-markdown-importer'); ?></h2>
@@ -13,4 +13,4 @@ defined('ABSPATH') || exit;
</li>
<?php endforeach; ?>
</ul>
</main>
</section>

View File

@@ -1,7 +1,7 @@
<?php
defined('ABSPATH') || exit;
?>
<main class="kb-docs-wrap">
<section class="kb-docs-version">
<nav class="kb-breadcrumbs">
<a href="<?php echo esc_url($url_builder::docsIndex()); ?>"><?php esc_html_e('Docs', 'kb-markdown-importer'); ?></a><span>/</span>
<a href="<?php echo esc_url($url_builder::product($product->slug)); ?>"><?php echo esc_html($product->name); ?></a><span>/</span>
@@ -14,4 +14,4 @@ defined('ABSPATH') || exit;
<li><a href="<?php echo esc_url($url_builder::page($product->slug, $version->slug, $slug)); ?>"><?php echo esc_html(get_the_title($page)); ?></a></li>
<?php endforeach; ?>
</ul>
</main>
</section>