routeFromRequestUri(); if (! $route) { return $queryVars; } $queryVars = [ 'kb_markdown_route' => $route['route'], ]; if (! empty($route['product'])) { $queryVars['kb_product_slug'] = $route['product']; } if (! empty($route['version'])) { $queryVars['kb_version_slug'] = $route['version']; } if (! empty($route['page'])) { $queryVars['kb_page_slug'] = $route['page']; } return $queryVars; } public function dispatch(): void { $route = get_query_var('kb_markdown_route'); $requestRoute = $this->routeFromRequestUri(); if (! $route && $requestRoute) { $route = $requestRoute['route']; } if (! $route) { $queryRoute = $this->routeFromQuery(); if (! $queryRoute) { return; } $requestRoute = $queryRoute; $route = $queryRoute['route']; } (new AccessController())->enforce(); get_header(); match ($route) { 'index' => $this->renderIndex(), 'product' => $this->renderProduct((string) ($requestRoute['product'] ?? get_query_var('kb_product_slug'))), 'version' => $this->renderVersion((string) ($requestRoute['product'] ?? get_query_var('kb_product_slug')), (string) ($requestRoute['version'] ?? get_query_var('kb_version_slug'))), 'page' => $this->renderPage((string) ($requestRoute['product'] ?? get_query_var('kb_product_slug')), (string) ($requestRoute['version'] ?? get_query_var('kb_version_slug')), trim((string) ($requestRoute['page'] ?? get_query_var('kb_page_slug')), '/')), default => $this->render404(), }; get_footer(); exit; } private function routeFromRequestUri(): array { $base = trim((string) Plugin::settings()['docs_base_slug'], '/') ?: 'docs'; $path = (string) wp_parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH); $path = trim(rawurldecode($path), '/'); if ($path === $base) { return ['route' => 'index']; } if (! str_starts_with($path . '/', $base . '/')) { return []; } $parts = array_values(array_filter(explode('/', substr($path, strlen($base))), static fn (string $part): bool => '' !== $part)); if (1 === count($parts)) { return ['route' => 'product', 'product' => sanitize_title($parts[0])]; } if (2 === count($parts)) { return ['route' => 'version', 'product' => sanitize_title($parts[0]), 'version' => sanitize_title($parts[1])]; } return [ 'route' => 'page', 'product' => sanitize_title($parts[0] ?? ''), 'version' => sanitize_title($parts[1] ?? ''), 'page' => sanitize_title(implode('/', array_slice($parts, 2))), ]; } private function routeFromQuery(): array { $route = sanitize_key(wp_unslash((string) ($_GET['kb_markdown_route'] ?? ''))); if (! $route) { return []; } return [ 'route' => $route, 'product' => sanitize_title(wp_unslash((string) ($_GET['kb_product_slug'] ?? ''))), 'version' => sanitize_title(wp_unslash((string) ($_GET['kb_version_slug'] ?? ''))), 'page' => sanitize_title(wp_unslash((string) ($_GET['kb_page_slug'] ?? ''))), ]; } public static function shortcodeDocsIndex(): string { return (new self())->captureRoute('index'); } public static function shortcodeDocsApp(array $atts = []): string { if (! (new AccessController())->canView()) { return ''; } $atts = shortcode_atts([ 'product' => '', 'version' => '', 'page' => '', ], $atts, 'kb_docs'); $router = new self(); $baseUrl = get_permalink() ?: home_url(add_query_arg([], (string) ($_SERVER['REQUEST_URI'] ?? '/'))); UrlBuilder::beginEmbed($baseUrl); try { $route = sanitize_key(wp_unslash((string) ($_GET['kb_docs_route'] ?? ''))); $product = sanitize_title(wp_unslash((string) ($_GET['kb_docs_product'] ?? $atts['product']))); $version = sanitize_title(wp_unslash((string) ($_GET['kb_docs_version'] ?? $atts['version']))); $page = sanitize_title(wp_unslash((string) ($_GET['kb_docs_page'] ?? $atts['page']))); if (! $route) { $route = $product ? ($version ? ($page ? 'page' : 'version') : 'product') : 'index'; } return $router->captureRoute($route, $product, $version, $page); } finally { UrlBuilder::endEmbed(); } } public static function shortcodeProductIndex(array $atts): string { $atts = shortcode_atts(['product' => ''], $atts, 'kb_product_index'); $router = new self(); return $router->captureRoute('product', sanitize_title((string) $atts['product'])); } private function renderIndex(): void { echo $this->captureRoute('index'); } private function captureIndex(): string { $settings = Plugin::settings(); return (new TemplateLoader())->capture('documentation-index', [ 'products' => self::productsWithVersions(), 'settings' => $settings, 'updates' => ProductUpdatesFeed::items($settings), 'base_slug' => trim((string) $settings['docs_base_slug'], '/'), 'url_builder' => UrlBuilder::class, ]); } private function renderProduct(string $productSlug): void { echo $this->captureRoute('product', $productSlug); } private function captureProduct(string $productSlug): string { $productItem = self::frontendProduct($productSlug); $product = $productItem['term'] ?? null; if (! $product) { return $this->capture404(); } $productSlug = (string) $product->slug; $versions = $this->versionsForProduct($productSlug); return (new TemplateLoader())->capture('product', [ 'product' => $product, 'product_item' => $productItem, 'versions' => $versions, 'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'), 'url_builder' => UrlBuilder::class, ]); } private function renderVersion(string $productSlug, string $versionSlug): void { echo $this->captureRoute('version', $productSlug, $versionSlug); } private function captureVersion(string $productSlug, string $versionSlug): string { $productItem = self::frontendProduct($productSlug); $product = $productItem['term'] ?? null; $version = get_term_by('slug', $versionSlug, 'kb_version'); if (! $product || ! $version) { return $this->capture404(); } $productSlug = (string) $product->slug; $landing = $this->landingPageForVersion($productSlug, $versionSlug); if ($landing) { $landingSlugs = $this->pageLinkSlugs([$landing], $productSlug); return $this->captureDocPage($landing, $productSlug, $versionSlug, $productItem, (string) ($landingSlugs[$landing->ID] ?? '')); } $pages = $this->pagesForVersion($productSlug, $versionSlug); return (new TemplateLoader())->capture('version', [ 'product' => $product, 'product_item' => $productItem, 'version' => $version, 'versions' => $this->versionsForProduct($productSlug), 'pages' => $pages, 'page_link_slugs' => $this->pageLinkSlugs($pages, $productSlug), 'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'), 'url_builder' => UrlBuilder::class, ]); } private function renderPage(string $productSlug, string $versionSlug, string $pageSlug): void { echo $this->captureRoute('page', $productSlug, $versionSlug, $pageSlug); } private function capturePage(string $productSlug, string $versionSlug, string $pageSlug): string { $productItem = self::frontendProduct($productSlug); if ($productItem && isset($productItem['term']->slug)) { $productSlug = (string) $productItem['term']->slug; } $termSlugs = $this->sourceProductSlugs($productSlug); $sourceSlug = ''; $realPageSlug = $pageSlug; if (str_contains($pageSlug, '--')) { [$sourceSlug, $realPageSlug] = array_pad(explode('--', $pageSlug, 2), 2, ''); if (in_array($sourceSlug, $termSlugs, true)) { $termSlugs = [$sourceSlug]; } if ('index' === $realPageSlug) { $realPageSlug = ''; } } $post = (new PageRepository())->findFrontendPageInProducts($termSlugs, $versionSlug, $realPageSlug); if (! $post && '' === $realPageSlug) { $post = (new PageRepository())->findFrontendPageInProducts($termSlugs, $versionSlug, 'index'); } if (! $post) { return $this->capture404(); } return $this->captureDocPage($post, $productSlug, $versionSlug, $productItem, $pageSlug); } private function renderDocPage(\WP_Post $post, string $productSlug, string $versionSlug): void { echo $this->captureDocPage($post, $productSlug, $versionSlug); } private function captureDocPage(\WP_Post $post, string $productSlug, string $versionSlug, ?array $productItem = null, string $activePageSlug = ''): string { $productItem = $productItem ?: self::frontendProduct($productSlug); $product = $productItem['term'] ?? null; $version = get_term_by('slug', $versionSlug, 'kb_version'); $navTree = json_decode((string) get_post_meta($post->ID, '_kb_nav_tree', true), true); $pages = $this->pagesForVersion($productSlug, $versionSlug); return (new TemplateLoader())->capture('page', [ 'post' => $post, 'product' => $product, 'product_item' => $productItem, 'version' => $version, 'versions' => $this->versionsForProduct($productSlug), 'nav_tree' => is_array($navTree) ? $navTree : [], 'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'), 'product_slug' => $productSlug, 'version_slug' => $versionSlug, 'active_page_slug' => $activePageSlug, 'page_link_slugs' => $this->pageLinkSlugs($pages, $productSlug), 'url_builder' => UrlBuilder::class, ]); } private function captureRoute(string $route, string $productSlug = '', string $versionSlug = '', string $pageSlug = ''): string { $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 = []; $activeProduct = self::frontendProduct($productSlug); if ($activeProduct && isset($activeProduct['term']->slug)) { $productSlug = (string) $activeProduct['term']->slug; } if ($productSlug && $versionSlug) { $activePages = $this->pagesForVersion($productSlug, $versionSlug); if ('' === $pageSlug) { $landing = $this->landingPageForVersion($productSlug, $versionSlug); if ($landing) { $landingSlugs = $this->pageLinkSlugs([$landing], $productSlug); $pageSlug = (string) ($landingSlugs[$landing->ID] ?? ''); } } } return (new TemplateLoader())->capture('docs-app', [ 'content' => $content, 'products' => self::productsWithVersions(), 'active_product' => $activeProduct, 'active_product_slug' => $productSlug, 'active_version_slug' => $versionSlug, 'active_page_slug' => $pageSlug, 'active_pages' => $activePages, 'active_page_link_slugs' => $this->pageLinkSlugs($activePages, $productSlug), 'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'), 'url_builder' => UrlBuilder::class, ]); } private function render404(): void { status_header(404); (new TemplateLoader())->render('search', [ 'title' => __('Dokumentationsseite nicht gefunden.', 'kb-markdown-importer'), 'results' => [], 'query' => '', ]); } private function capture404(): string { status_header(404); return (new TemplateLoader())->capture('search', [ 'title' => __('Dokumentationsseite nicht gefunden.', 'kb-markdown-importer'), 'results' => [], 'query' => '', ]); } public static function productsWithVersions(): array { $products = get_terms(['taxonomy' => 'kb_product', 'hide_empty' => false]); $groups = []; $repository = new ProductRepository(); if (is_wp_error($products)) { return []; } foreach ($products as $product) { $meta = $repository->frontendMeta($product); $groupSlug = (string) $meta['group_slug']; if (! isset($groups[$groupSlug])) { $groups[$groupSlug] = [ 'term' => (object) [ 'term_id' => 0, 'name' => (string) $meta['group_name'], 'slug' => $groupSlug, ], 'source_terms' => [], 'parts' => [], 'category' => (string) $meta['category'], 'versions' => [], ]; } $groups[$groupSlug]['source_terms'][] = $product; $groups[$groupSlug]['parts'][$product->slug] = [ 'term' => $product, 'label' => (string) ($meta['part_label'] ?: $product->name), 'category' => (string) $meta['category'], ]; } $router = new self(); foreach ($groups as $groupSlug => &$group) { $group['versions'] = $router->versionsForProduct((string) $groupSlug); } unset($group); $groups = array_values(array_filter($groups, static fn (array $group): bool => ! empty($group['versions']))); usort($groups, static function (array $a, array $b): int { $categoryCompare = strcasecmp((string) ($a['category'] ?? ''), (string) ($b['category'] ?? '')); return 0 !== $categoryCompare ? $categoryCompare : strcasecmp((string) $a['term']->name, (string) $b['term']->name); }); return $groups; } public static function frontendProduct(string $productSlug): ?array { $items = self::productsWithVersions(); foreach ($items as $item) { if ($productSlug === (string) $item['term']->slug) { return $item; } } $term = get_term_by('slug', $productSlug, 'kb_product'); if ($term instanceof \WP_Term) { $meta = (new ProductRepository())->frontendMeta($term); foreach ($items as $item) { if ((string) $meta['group_slug'] === (string) $item['term']->slug) { return $item; } } } return null; } private function versionsForProduct(string $productSlug): array { $productSlugs = $this->sourceProductSlugs($productSlug); if (! $productSlugs) { return []; } $query = new \WP_Query([ 'post_type' => 'kb_doc_page', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'tax_query' => [ ['taxonomy' => 'kb_product', 'field' => 'slug', 'terms' => $productSlugs], ], ]); $versions = []; foreach ($query->posts as $postId) { foreach (wp_get_object_terms((int) $postId, 'kb_version') as $term) { $versions[$term->slug] = $term; } } uasort($versions, static fn ($a, $b): int => strnatcasecmp($b->name, $a->name)); return array_values($versions); } private function pagesForVersion(string $productSlug, string $versionSlug): array { $productSlugs = $this->sourceProductSlugs($productSlug); if (! $productSlugs) { return []; } $query = new \WP_Query([ 'post_type' => 'kb_doc_page', 'post_status' => 'publish', 'posts_per_page' => -1, 'meta_key' => '_kb_nav_order', 'orderby' => ['meta_value_num' => 'ASC', 'title' => 'ASC'], 'tax_query' => [ 'relation' => 'AND', ['taxonomy' => 'kb_product', 'field' => 'slug', 'terms' => $productSlugs], ['taxonomy' => 'kb_version', 'field' => 'slug', 'terms' => $versionSlug], ], ]); return $query->posts; } private function landingPageForVersion(string $productSlug, string $versionSlug): ?\WP_Post { $repository = new PageRepository(); $landing = $repository->findFrontendPageInProducts($this->sourceProductSlugs($productSlug), $versionSlug, ''); if ($landing) { return $landing; } $pages = $this->pagesForVersion($productSlug, $versionSlug); return $pages[0] ?? null; } private function sourceProductSlugs(string $productSlug): array { $terms = get_terms(['taxonomy' => 'kb_product', 'hide_empty' => false]); $repository = new ProductRepository(); $slugs = []; if (is_wp_error($terms)) { return [$productSlug]; } foreach ($terms as $term) { $meta = $repository->frontendMeta($term); if ($productSlug === (string) $meta['group_slug']) { $slugs[] = $term->slug; } } return $slugs ?: [$productSlug]; } private function pageLinkSlugs(array $pages, string $productSlug): array { $product = self::frontendProduct($productSlug); $sourceTerms = (array) ($product['source_terms'] ?? []); $multiPart = count($sourceTerms) > 1; $slugs = []; foreach ($pages as $page) { if (! $page instanceof \WP_Post) { continue; } $pageSlug = (string) get_post_meta($page->ID, '_kb_page_slug', true); $sourceSlug = (string) get_post_meta($page->ID, '_kb_product_slug', true); $slugs[$page->ID] = ($multiPart && '' !== $sourceSlug) ? $sourceSlug . '--' . ($pageSlug ?: 'index') : $pageSlug; } return $slugs; } }