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

@@ -186,6 +186,7 @@ Shortcodes:
```
`[kb_docs]` bindet die Dokumentation in eine normale WordPress-Seite ein.
Die Ausgabe ist als Doku-App aufgebaut: Startseite rechts, persistente Sidebar links. Die Sidebar zeigt alle Produkte, deren Versionen und fuer die aktive Version alle Seiten, sodass man direkt von der Portalseite in die konkrete Doku springen kann.
## 10. Admin-Einstellungen
@@ -194,10 +195,13 @@ Backend-Menue:
```text
Knowledgebase
Uebersicht
Produkte
Synchronisation
Einstellungen
```
Unter `Produkte` koennen importierte Produkte verwaltet werden. Admins koennen Namen und Slugs korrigieren oder ein fehlerhaft importiertes Produkt inklusive der zugehoerigen Doku-Seiten in den Papierkorb verschieben.
Einstellungen:
- GitLab Base URL
@@ -239,7 +243,9 @@ Es gibt keine Renderer-Modus-Einstellung mehr. Markdown wird direkt im Plugin ve
- Bilder aus `images/` werden importiert und im HTML ersetzt.
- Interne `.md`-Links funktionieren.
- `/docs/` zeigt die Dokumentationsuebersicht.
- `[kb_docs]` zeigt eine Startseite mit persistenter Produkt-, Versions- und Seitennavigation.
- `/docs/{product}/{version}/` zeigt die Startseite.
- Im Backend koennen Produkte bei fehlerhaften Importen verwaltet und entfernt werden.
- Synchronisation dupliziert unveraenderte Seiten nicht.
- Importfehler werden im Backend-Log sichtbar.

Binary file not shown.

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,47 +1,6 @@
<?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>
@@ -56,4 +15,3 @@ $render_nav = static function (array $nodes) use (&$render_nav, $base_slug, $pro
?>
</div>
</article>
</main>

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>