Files
2026-05-27 14:17:22 +02:00

589 lines
21 KiB
PHP

<?php
declare(strict_types=1);
namespace KbMarkdownImporter\Frontend;
use KbMarkdownImporter\Access\AccessController;
use KbMarkdownImporter\Plugin;
use KbMarkdownImporter\Repository\PageRepository;
use KbMarkdownImporter\Repository\ProductRepository;
final class Router
{
public function boot(): void
{
add_action('init', [$this, 'addRewriteRules']);
add_filter('request', [$this, 'routeRequest']);
add_filter('query_vars', [$this, 'addQueryVars']);
add_action('template_redirect', [$this, 'dispatch']);
}
public function addRewriteRules(): void
{
$base = trim((string) Plugin::settings()['docs_base_slug'], '/') ?: 'docs';
add_rewrite_rule('^' . preg_quote($base, '#') . '/?$', 'index.php?kb_markdown_route=index', 'top');
add_rewrite_rule('^' . preg_quote($base, '#') . '/([^/]+)/?$', 'index.php?kb_markdown_route=product&kb_product_slug=$matches[1]', 'top');
add_rewrite_rule('^' . preg_quote($base, '#') . '/([^/]+)/([^/]+)/?$', 'index.php?kb_markdown_route=version&kb_product_slug=$matches[1]&kb_version_slug=$matches[2]', 'top');
add_rewrite_rule('^' . preg_quote($base, '#') . '/([^/]+)/([^/]+)/(.+?)/?$', 'index.php?kb_markdown_route=page&kb_product_slug=$matches[1]&kb_version_slug=$matches[2]&kb_page_slug=$matches[3]', 'top');
}
public function addQueryVars(array $vars): array
{
return array_merge($vars, ['kb_markdown_route', 'kb_product_slug', 'kb_version_slug', 'kb_page_slug']);
}
public function routeRequest(array $queryVars): array
{
$route = $this->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;
}
}