diff --git a/codex.md b/codex.md index a34926b..2ccd6ab 100644 --- a/codex.md +++ b/codex.md @@ -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. diff --git a/kb-markdown-importer.zip b/kb-markdown-importer.zip index 21c3651..f001bc1 100644 Binary files a/kb-markdown-importer.zip and b/kb-markdown-importer.zip differ diff --git a/kb-markdown-importer/assets/css/frontend.css b/kb-markdown-importer/assets/css/frontend.css index 0f8ee0e..09e02e8 100644 --- a/kb-markdown-importer/assets/css/frontend.css +++ b/kb-markdown-importer/assets/css/frontend.css @@ -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; } diff --git a/kb-markdown-importer/assets/css/themes/obyte.css b/kb-markdown-importer/assets/css/themes/obyte.css index 42e919a..7535710 100644 --- a/kb-markdown-importer/assets/css/themes/obyte.css +++ b/kb-markdown-importer/assets/css/themes/obyte.css @@ -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; diff --git a/kb-markdown-importer/includes/Admin/ProductsPage.php b/kb-markdown-importer/includes/Admin/ProductsPage.php new file mode 100644 index 0000000..a01cd3a --- /dev/null +++ b/kb-markdown-importer/includes/Admin/ProductsPage.php @@ -0,0 +1,125 @@ +allWithStats(); + ?> +
+

+

+ + + + + + + + + + + + + + + + + + term_id; + $versions = array_map(static fn (\WP_Term $version): string => $version->name, (array) $item['versions']); + ?> + + + + + + + + + +
+ + + + +
+ term_id); ?> + + + +
+
+ term_id); ?> + + + + +
+
+
+ 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'); + } + } +} diff --git a/kb-markdown-importer/includes/Frontend/Router.php b/kb-markdown-importer/includes/Frontend/Router.php index 120d490..a20a3b7 100644 --- a/kb-markdown-importer/includes/Frontend/Router.php +++ b/kb-markdown-importer/includes/Frontend/Router.php @@ -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 diff --git a/kb-markdown-importer/includes/Plugin.php b/kb-markdown-importer/includes/Plugin.php index f7fcfc8..f9157b0 100644 --- a/kb-markdown-importer/includes/Plugin.php +++ b/kb-markdown-importer/includes/Plugin.php @@ -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']); } diff --git a/kb-markdown-importer/includes/Repository/ProductRepository.php b/kb-markdown-importer/includes/Repository/ProductRepository.php index 535ec47..00c2f2a 100644 --- a/kb-markdown-importer/includes/Repository/ProductRepository.php +++ b/kb-markdown-importer/includes/Repository/ProductRepository.php @@ -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); + } } diff --git a/kb-markdown-importer/templates/docs-app.php b/kb-markdown-importer/templates/docs-app.php new file mode 100644 index 0000000..83e902c --- /dev/null +++ b/kb-markdown-importer/templates/docs-app.php @@ -0,0 +1,58 @@ + +
+ +
+ +
+
diff --git a/kb-markdown-importer/templates/documentation-index.php b/kb-markdown-importer/templates/documentation-index.php index 98a8563..fa9c212 100644 --- a/kb-markdown-importer/templates/documentation-index.php +++ b/kb-markdown-importer/templates/documentation-index.php @@ -1,14 +1,14 @@ -
+

-
+
-

name); ?>

+

name); ?>

    @@ -19,4 +19,4 @@ defined('ABSPATH') || exit;
-
+ diff --git a/kb-markdown-importer/templates/page.php b/kb-markdown-importer/templates/page.php index 91d8dc7..2b0fc8a 100644 --- a/kb-markdown-importer/templates/page.php +++ b/kb-markdown-importer/templates/page.php @@ -1,59 +1,17 @@ '; - 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 '
  • '; - if ($href) { - printf('%s', esc_url($href), esc_html($label)); - } else { - echo '' . esc_html($label) . ''; - } - $render_nav((array) ($node['children'] ?? [])); - echo '
  • '; - } - echo ''; -}; ?> -
    - -
    - -

    -
    - post_content)); - echo wp_kses_post($rendered_content); - ?> -
    -
    -
    +
    + +

    +
    + post_content)); + echo wp_kses_post($rendered_content); + ?> +
    +
    diff --git a/kb-markdown-importer/templates/product.php b/kb-markdown-importer/templates/product.php index 2a84464..12a54f5 100644 --- a/kb-markdown-importer/templates/product.php +++ b/kb-markdown-importer/templates/product.php @@ -1,7 +1,7 @@ -
    +

    name); ?>

    @@ -13,4 +13,4 @@ defined('ABSPATH') || exit; -
    + diff --git a/kb-markdown-importer/templates/version.php b/kb-markdown-importer/templates/version.php index 449f43a..9f47458 100644 --- a/kb-markdown-importer/templates/version.php +++ b/kb-markdown-importer/templates/version.php @@ -1,7 +1,7 @@ -
    +
    +