From 6abf6f9c3d7d7549ea2c2199704b90c04f4b8b26 Mon Sep 17 00:00:00 2001 From: Sven Steinert Date: Tue, 12 May 2026 14:37:09 +0200 Subject: [PATCH] initial COmmit: Add KB Antora Importer plugin files --- kb-antora-importer.zip | Bin 0 -> 40103 bytes kb-antora-importer/assets/css/frontend.css | 210 ++++++++++ kb-antora-importer/assets/js/frontend.js | 13 + kb-antora-importer/composer.json | 14 + .../includes/Access/AccessController.php | 25 ++ .../includes/Admin/SettingsPage.php | 171 ++++++++ .../includes/Admin/StatusPage.php | 78 ++++ .../includes/Admin/SyncPage.php | 98 +++++ .../includes/Antora/AntoraNavParser.php | 67 +++ .../includes/Antora/AntoraParser.php | 22 + .../Antora/AntoraResourceResolver.php | 17 + .../includes/Antora/AntoraYamlReader.php | 44 ++ .../includes/AsciiDoc/AsciiDocRenderer.php | 132 ++++++ .../AsciiDoc/ShortcodeTransformer.php | 59 +++ .../includes/Frontend/BreadcrumbBuilder.php | 30 ++ .../includes/Frontend/Router.php | 385 ++++++++++++++++++ .../includes/Frontend/SearchController.php | 69 ++++ .../includes/Frontend/TemplateLoader.php | 28 ++ .../includes/Frontend/UrlBuilder.php | 163 ++++++++ .../includes/GitLab/GitLabBranch.php | 13 + .../includes/GitLab/GitLabClient.php | 194 +++++++++ .../includes/GitLab/GitLabProject.php | 14 + .../includes/Import/Checksum.php | 12 + .../includes/Import/ImportJob.php | 14 + .../includes/Import/ImportLogger.php | 61 +++ .../includes/Import/ImportManager.php | 382 +++++++++++++++++ kb-antora-importer/includes/Plugin.php | 231 +++++++++++ .../includes/Repository/PageRepository.php | 130 ++++++ .../includes/Repository/ProductRepository.php | 19 + .../includes/Repository/VersionRepository.php | 18 + kb-antora-importer/includes/Settings.php | 24 ++ kb-antora-importer/kb-antora-importer.php | 42 ++ kb-antora-importer/readme.md | 18 + .../templates/documentation-index.php | 22 + kb-antora-importer/templates/page.php | 56 +++ kb-antora-importer/templates/product.php | 16 + kb-antora-importer/templates/search.php | 26 ++ kb-antora-importer/templates/version.php | 17 + kb-antora-importer/uninstall.php | 11 + 39 files changed, 2945 insertions(+) create mode 100644 kb-antora-importer.zip create mode 100644 kb-antora-importer/assets/css/frontend.css create mode 100644 kb-antora-importer/assets/js/frontend.js create mode 100644 kb-antora-importer/composer.json create mode 100644 kb-antora-importer/includes/Access/AccessController.php create mode 100644 kb-antora-importer/includes/Admin/SettingsPage.php create mode 100644 kb-antora-importer/includes/Admin/StatusPage.php create mode 100644 kb-antora-importer/includes/Admin/SyncPage.php create mode 100644 kb-antora-importer/includes/Antora/AntoraNavParser.php create mode 100644 kb-antora-importer/includes/Antora/AntoraParser.php create mode 100644 kb-antora-importer/includes/Antora/AntoraResourceResolver.php create mode 100644 kb-antora-importer/includes/Antora/AntoraYamlReader.php create mode 100644 kb-antora-importer/includes/AsciiDoc/AsciiDocRenderer.php create mode 100644 kb-antora-importer/includes/AsciiDoc/ShortcodeTransformer.php create mode 100644 kb-antora-importer/includes/Frontend/BreadcrumbBuilder.php create mode 100644 kb-antora-importer/includes/Frontend/Router.php create mode 100644 kb-antora-importer/includes/Frontend/SearchController.php create mode 100644 kb-antora-importer/includes/Frontend/TemplateLoader.php create mode 100644 kb-antora-importer/includes/Frontend/UrlBuilder.php create mode 100644 kb-antora-importer/includes/GitLab/GitLabBranch.php create mode 100644 kb-antora-importer/includes/GitLab/GitLabClient.php create mode 100644 kb-antora-importer/includes/GitLab/GitLabProject.php create mode 100644 kb-antora-importer/includes/Import/Checksum.php create mode 100644 kb-antora-importer/includes/Import/ImportJob.php create mode 100644 kb-antora-importer/includes/Import/ImportLogger.php create mode 100644 kb-antora-importer/includes/Import/ImportManager.php create mode 100644 kb-antora-importer/includes/Plugin.php create mode 100644 kb-antora-importer/includes/Repository/PageRepository.php create mode 100644 kb-antora-importer/includes/Repository/ProductRepository.php create mode 100644 kb-antora-importer/includes/Repository/VersionRepository.php create mode 100644 kb-antora-importer/includes/Settings.php create mode 100644 kb-antora-importer/kb-antora-importer.php create mode 100644 kb-antora-importer/readme.md create mode 100644 kb-antora-importer/templates/documentation-index.php create mode 100644 kb-antora-importer/templates/page.php create mode 100644 kb-antora-importer/templates/product.php create mode 100644 kb-antora-importer/templates/search.php create mode 100644 kb-antora-importer/templates/version.php create mode 100644 kb-antora-importer/uninstall.php diff --git a/kb-antora-importer.zip b/kb-antora-importer.zip new file mode 100644 index 0000000000000000000000000000000000000000..f2473fa0de1c4784439d74df05a42cdbe88f212c GIT binary patch literal 40103 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 h1 { + margin-top: 0; + margin-bottom: 22px; + font-size: clamp(28px, 4vw, 42px); + line-height: 1.1; +} + +.kb-rendered-content h1 { + display: none; +} + +.kb-rendered-content h2, +.kb-rendered-content h3, +.kb-rendered-content h4 { + scroll-margin-top: 24px; + margin-top: 32px; +} + +.kb-rendered-content p, +.kb-rendered-content li { + line-height: 1.7; +} + +.kb-rendered-content a { + color: var(--kb-accent); + text-underline-offset: 3px; +} + +.kb-admonition { + border-left: 4px solid var(--kb-accent); + border-radius: 0 6px 6px 0; + padding: 14px 16px; + background: var(--kb-accent-soft); + margin: 18px 0; +} + +.kb-image img { + max-width: 100%; + height: auto; +} + +.kb-current-version { + margin-left: 8px; + font-size: 12px; + color: var(--kb-accent); +} + +.kb-search-form { + display: flex; + gap: 8px; +} + +.kb-search button { + min-height: 40px; + border: 0; + border-radius: 6px; + padding: 0 16px; + background: var(--kb-accent); + color: #fff; + cursor: pointer; +} + +@media (max-width: 780px) { + .kb-doc-layout { + grid-template-columns: 1fr; + } + + .kb-sidebar { + position: static; + max-height: none; + border-right: 0; + border-bottom: 1px solid var(--kb-border); + padding-right: 0; + padding-bottom: 18px; + } +} diff --git a/kb-antora-importer/assets/js/frontend.js b/kb-antora-importer/assets/js/frontend.js new file mode 100644 index 0000000..2b9efc5 --- /dev/null +++ b/kb-antora-importer/assets/js/frontend.js @@ -0,0 +1,13 @@ +(function () { + const switcher = document.getElementById('kb-version-switcher'); + if (!switcher) { + return; + } + + switcher.addEventListener('change', function () { + const selected = switcher.options[switcher.selectedIndex]; + if (selected && selected.dataset.url) { + window.location.href = selected.dataset.url; + } + }); +}()); diff --git a/kb-antora-importer/composer.json b/kb-antora-importer/composer.json new file mode 100644 index 0000000..53ef416 --- /dev/null +++ b/kb-antora-importer/composer.json @@ -0,0 +1,14 @@ +{ + "name": "kb-antora-importer/kb-antora-importer", + "description": "WordPress plugin for importing GitLab/Antora based AsciiDoc documentation.", + "type": "wordpress-plugin", + "license": "proprietary", + "require": { + "php": ">=8.1" + }, + "autoload": { + "psr-4": { + "KbAntoraImporter\\": "includes/" + } + } +} diff --git a/kb-antora-importer/includes/Access/AccessController.php b/kb-antora-importer/includes/Access/AccessController.php new file mode 100644 index 0000000..ac271d1 --- /dev/null +++ b/kb-antora-importer/includes/Access/AccessController.php @@ -0,0 +1,25 @@ +canView()) { + return; + } + + auth_redirect(); + } +} diff --git a/kb-antora-importer/includes/Admin/SettingsPage.php b/kb-antora-importer/includes/Admin/SettingsPage.php new file mode 100644 index 0000000..8ef35da --- /dev/null +++ b/kb-antora-importer/includes/Admin/SettingsPage.php @@ -0,0 +1,171 @@ + 'array', + 'sanitize_callback' => [self::class, 'sanitize'], + 'default' => Settings::defaults(), + ]); + } + + public static function sanitize(array $input): array + { + $old = Plugin::settings(); + $settings = Settings::defaults(); + + $settings['gitlab_base_url'] = esc_url_raw(GitLabClient::normalizeBaseUrl((string) ($input['gitlab_base_url'] ?? ''))); + $settings['gitlab_token'] = trim((string) ($input['gitlab_token'] ?? '')) ?: (string) $old['gitlab_token']; + $settings['gitlab_group'] = sanitize_text_field((string) ($input['gitlab_group'] ?? 'knowledgebase')); + $settings['branch_pattern'] = sanitize_text_field((string) ($input['branch_pattern'] ?? '^v.*')); + $settings['docs_base_slug'] = sanitize_title((string) ($input['docs_base_slug'] ?? 'docs')) ?: 'docs'; + $settings['renderer_mode'] = in_array(($input['renderer_mode'] ?? 'php'), ['php', 'asciidoctor'], true) ? (string) $input['renderer_mode'] : 'php'; + $settings['asciidoctor_path'] = sanitize_text_field((string) ($input['asciidoctor_path'] ?? 'asciidoctor')); + $settings['image_lightbox'] = ! empty($input['image_lightbox']) ? '1' : '0'; + $settings['public_docs'] = ! empty($input['public_docs']) ? '1' : '0'; + $settings['allow_svg'] = ! empty($input['allow_svg']) ? '1' : '0'; + $settings['cron_interval'] = in_array(($input['cron_interval'] ?? 'disabled'), ['disabled', 'hourly', 'daily', 'weekly'], true) ? (string) $input['cron_interval'] : 'disabled'; + + Plugin::syncCronSchedule($settings); + if (($old['docs_base_slug'] ?? 'docs') !== $settings['docs_base_slug']) { + flush_rewrite_rules(false); + } + + return $settings; + } + + public static function render(): void + { + if (! current_user_can('manage_kb_docs')) { + wp_die(esc_html__('Insufficient permissions.', 'kb-antora-importer')); + } + + if (isset($_POST['kb_antora_test_connection']) && check_admin_referer('kb_antora_test_connection')) { + self::handleConnectionTest(); + } + + $settings = Plugin::settings(); + ?> +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ getGroup(Plugin::settings()['gitlab_group']); + + if (is_wp_error($result)) { + $message = self::formatConnectionError($result); + ImportLogger::error('GitLab connection failed: ' . $message); + add_settings_error('kb_antora_importer', 'connection_failed', esc_html($message), 'error'); + settings_errors('kb_antora_importer'); + return; + } + + ImportLogger::info('GitLab connection successful.'); + add_settings_error('kb_antora_importer', 'connection_ok', esc_html__('GitLab connection successful.', 'kb-antora-importer'), 'success'); + settings_errors('kb_antora_importer'); + } + + private static function formatConnectionError(\WP_Error $error): string + { + $message = $error->get_error_message(); + $data = $error->get_error_data(); + + if (! is_array($data)) { + return $message; + } + + if (! empty($data['url'])) { + $message .= ' Target: ' . $data['url']; + } + + if (! empty($data['retry_after'])) { + $message .= ' Retry-After: ' . $data['retry_after']; + } + + if (! empty($data['response_excerpt'])) { + $message .= ' Response: ' . $data['response_excerpt']; + } + + return $message; + } +} diff --git a/kb-antora-importer/includes/Admin/StatusPage.php b/kb-antora-importer/includes/Admin/StatusPage.php new file mode 100644 index 0000000..a63e2af --- /dev/null +++ b/kb-antora-importer/includes/Admin/StatusPage.php @@ -0,0 +1,78 @@ + +
+

+
+
GitLab
+
Products
+
Versions
+
Pages
+
Last sync
+
Renderer
+
+

+ +
+ (bool) (Plugin::settings()['gitlab_base_url'] && Plugin::settings()['gitlab_token']), + 'counts' => self::counts(), + 'last_sync' => get_option('kb_antora_importer_last_sync', ''), + 'last_error' => get_option('kb_antora_importer_last_error', ''), + ]); + } + + public static function renderLogTable(array $logs): void + { + if (! $logs) { + echo '

' . esc_html__('No logs yet.', 'kb-antora-importer') . '

'; + return; + } + + echo ''; + foreach ($logs as $entry) { + printf( + '', + esc_html((string) ($entry['time'] ?? '')), + esc_html((string) ($entry['level'] ?? 'INFO')), + esc_html((string) ($entry['message'] ?? '')) + ); + } + echo '
TimeLevelMessage
%s%s%s
'; + } + + private static function counts(): array + { + $pages = wp_count_posts('kb_doc_page'); + $products = wp_count_terms(['taxonomy' => 'kb_product', 'hide_empty' => false]); + $versions = wp_count_terms(['taxonomy' => 'kb_version', 'hide_empty' => false]); + + return [ + 'products' => is_wp_error($products) ? 0 : (int) $products, + 'versions' => is_wp_error($versions) ? 0 : (int) $versions, + 'pages' => (int) ($pages->publish ?? 0), + ]; + } +} diff --git a/kb-antora-importer/includes/Admin/SyncPage.php b/kb-antora-importer/includes/Admin/SyncPage.php new file mode 100644 index 0000000..33cc1ed --- /dev/null +++ b/kb-antora-importer/includes/Admin/SyncPage.php @@ -0,0 +1,98 @@ + +
+

+
+ + + +
+ +

+ +

get_error_message()); ?>

+ +

+ + + + + + + + + + + + +
NamePathAction
+
+ + + +
+
+ + +

+ +
+ syncAll(false); + echo '

' . esc_html__('Synchronization finished.', 'kb-antora-importer') . '

'; + } + + if (isset($_POST['kb_antora_dry_run']) && check_admin_referer('kb_antora_sync')) { + (new ImportManager())->syncAll(true); + echo '

' . esc_html__('Dry run finished.', 'kb-antora-importer') . '

'; + } + + if (isset($_POST['kb_antora_sync_project']) && check_admin_referer('kb_antora_sync_project')) { + $projectId = sanitize_text_field(wp_unslash((string) ($_POST['project_id'] ?? ''))); + (new ImportManager())->syncProject($projectId, false); + echo '

' . esc_html__('Project synchronization finished.', 'kb-antora-importer') . '

'; + } + } + + private static function loadProjects(): array|\WP_Error + { + $settings = Plugin::settings(); + + if (! $settings['gitlab_base_url'] || ! $settings['gitlab_token'] || ! $settings['gitlab_group']) { + return []; + } + + $client = new GitLabClient($settings); + $group = $client->getGroup($settings['gitlab_group']); + + if (is_wp_error($group)) { + return $group; + } + + return $client->getProjects((string) ($group['id'] ?? $settings['gitlab_group'])); + } +} diff --git a/kb-antora-importer/includes/Antora/AntoraNavParser.php b/kb-antora-importer/includes/Antora/AntoraNavParser.php new file mode 100644 index 0000000..42a9cc2 --- /dev/null +++ b/kb-antora-importer/includes/Antora/AntoraNavParser.php @@ -0,0 +1,67 @@ + $raw, + 'target' => '', + 'children' => [], + ]; + + if (preg_match('/xref:([^\[]+)\[([^\]]*)\]/', $raw, $xref)) { + $item['target'] = trim($xref[1]); + $item['title'] = trim($xref[2]) ?: basename($item['target']); + } + + while (count($stack) >= $level) { + array_pop($stack); + } + + if (empty($stack)) { + $root[] = $item; + $stack[$level - 1] = &$root[array_key_last($root)]; + } else { + $parent = &$stack[array_key_last($stack)]; + $parent['children'][] = $item; + $stack[$level - 1] = &$parent['children'][array_key_last($parent['children'])]; + } + + unset($parent); + } + + return $root; + } + + public function flatten(array $tree): array + { + $items = []; + $walk = static function (array $nodes, int $level = 1) use (&$walk, &$items): void { + foreach ($nodes as $node) { + $items[] = [ + 'title' => (string) ($node['title'] ?? ''), + 'target' => (string) ($node['target'] ?? ''), + 'level' => $level, + ]; + $walk((array) ($node['children'] ?? []), $level + 1); + } + }; + $walk($tree); + + return $items; + } +} diff --git a/kb-antora-importer/includes/Antora/AntoraParser.php b/kb-antora-importer/includes/Antora/AntoraParser.php new file mode 100644 index 0000000..b7d9d24 --- /dev/null +++ b/kb-antora-importer/includes/Antora/AntoraParser.php @@ -0,0 +1,22 @@ + '', + 'title' => '', + 'version' => '', + 'nav' => [], + ]; + + $inNav = false; + foreach (preg_split('/\R/', $yaml) ?: [] as $line) { + $trimmed = trim($line); + + if ('' === $trimmed || str_starts_with($trimmed, '#')) { + continue; + } + + if (preg_match('/^([a-zA-Z0-9_-]+):\s*(.*)$/', $trimmed, $matches)) { + $key = $matches[1]; + $value = trim($matches[2], " \"'"); + $inNav = 'nav' === $key; + + if (array_key_exists($key, $data) && 'nav' !== $key) { + $data[$key] = $value; + } + + continue; + } + + if ($inNav && preg_match('/^-\s*(.+)$/', $trimmed, $matches)) { + $data['nav'][] = trim($matches[1], " \"'"); + } + } + + return $data; + } +} diff --git a/kb-antora-importer/includes/AsciiDoc/AsciiDocRenderer.php b/kb-antora-importer/includes/AsciiDoc/AsciiDocRenderer.php new file mode 100644 index 0000000..290aeda --- /dev/null +++ b/kb-antora-importer/includes/AsciiDoc/AsciiDocRenderer.php @@ -0,0 +1,132 @@ +transformer = $transformer ?: new ShortcodeTransformer(); + } + + public function render(string $adoc, array $context = []): string + { + $lines = preg_split('/\R/', $adoc) ?: []; + $html = ''; + $paragraph = []; + $listOpen = false; + $codeOpen = false; + $code = []; + + $flushParagraph = function () use (&$html, &$paragraph, $context): void { + if (! $paragraph) { + return; + } + + $text = implode(' ', array_map('trim', $paragraph)); + $html .= '

' . $this->transformer->transformInline($text, $context) . '

' . "\n"; + $paragraph = []; + }; + + $closeList = static function () use (&$html, &$listOpen): void { + if ($listOpen) { + $html .= "\n"; + $listOpen = false; + } + }; + + foreach ($lines as $line) { + $trimmed = trim($line); + + if ('----' === $trimmed) { + $flushParagraph(); + $closeList(); + if ($codeOpen) { + $html .= '
' . esc_html(implode("\n", $code)) . '
' . "\n"; + $code = []; + $codeOpen = false; + } else { + $codeOpen = true; + } + continue; + } + + if ($codeOpen) { + $code[] = $line; + continue; + } + + if ('' === $trimmed) { + $flushParagraph(); + $closeList(); + continue; + } + + if (preg_match('/^:[A-Za-z0-9_-]+:\s*/', $trimmed)) { + continue; + } + + if (preg_match('/^(={1,6})\s+(.+)$/', $trimmed, $matches)) { + $flushParagraph(); + $closeList(); + $level = min(6, strlen($matches[1])); + $html .= sprintf('%s', $level, esc_html($matches[2]), $level) . "\n"; + continue; + } + + if (preg_match('/^\*\s+(.+)$/', $trimmed, $matches)) { + $flushParagraph(); + if (! $listOpen) { + $html .= "
    \n"; + $listOpen = true; + } + $html .= '
  • ' . $this->transformer->transformInline($matches[1], $context) . '
  • ' . "\n"; + continue; + } + + if (preg_match('/^(NOTE|TIP|IMPORTANT|WARNING|CAUTION):\s+(.+)$/', $trimmed, $matches)) { + $flushParagraph(); + $closeList(); + $class = strtolower($matches[1]); + $html .= '' . "\n"; + continue; + } + + if (preg_match('/^image::([^\[]+)\[([^\]]*)\]/', $trimmed, $matches)) { + $flushParagraph(); + $closeList(); + $imageName = trim($matches[1]); + $alt = trim($matches[2]) ?: basename($imageName); + $url = (string) ($context['images'][$imageName] ?? $context['images'][basename($imageName)] ?? ''); + + if ($url) { + $image = sprintf('%s', esc_url($url), esc_attr($alt)); + if (! empty($context['lightbox'])) { + $image = sprintf('%s', esc_url($url), $image); + } + $html .= '
    ' . $image . '
    ' . "\n"; + } else { + $html .= '
    ' . esc_html($alt) . '
    ' . "\n"; + } + continue; + } + + if (preg_match('/^\|===/', $trimmed)) { + $flushParagraph(); + $closeList(); + $html .= "
    \n"; + continue; + } + + $paragraph[] = $line; + } + + $flushParagraph(); + $closeList(); + + return wp_kses_post($html); + } +} diff --git a/kb-antora-importer/includes/AsciiDoc/ShortcodeTransformer.php b/kb-antora-importer/includes/AsciiDoc/ShortcodeTransformer.php new file mode 100644 index 0000000..55582e7 --- /dev/null +++ b/kb-antora-importer/includes/AsciiDoc/ShortcodeTransformer.php @@ -0,0 +1,59 @@ +renderLink((string) $match[1][0], (string) $match[2][0], (string) $match[3][0], $context); + $offset = $start + strlen((string) $match[0][0]); + } + + $output .= esc_html(substr($text, $offset)); + + return $output; + } + + private function renderLink(string $type, string $target, string $label, array $context): string + { + $target = trim($target); + $label = $label ?: basename($target); + + if ('link' === $type && preg_match('#^https?://#i', $target)) { + return sprintf('%s', esc_url($target), esc_html($label)); + } + + if (preg_match('#^https?://#i', $target)) { + return sprintf('%s', esc_url($target), esc_html($label)); + } + + $target = preg_replace('/^[^:]+:/', '', $target) ?: $target; + $fragment = ''; + + if (str_contains($target, '#')) { + [$target, $fragment] = explode('#', $target, 2); + $fragment = '#' . sanitize_title($fragment); + } + + $target = preg_replace('/\.adoc$/', '', $target) ?: $target; + $slug = in_array(basename($target), ['index', 'dokumentation'], true) ? '' : sanitize_title(basename($target)); + $url = UrlBuilder::page((string) $context['product_slug'], (string) $context['version_slug'], $slug) . $fragment; + + return sprintf('%s', esc_url($url), esc_html($label)); + } +} diff --git a/kb-antora-importer/includes/Frontend/BreadcrumbBuilder.php b/kb-antora-importer/includes/Frontend/BreadcrumbBuilder.php new file mode 100644 index 0000000..f76495c --- /dev/null +++ b/kb-antora-importer/includes/Frontend/BreadcrumbBuilder.php @@ -0,0 +1,30 @@ +%s', esc_url(home_url('/' . $base . '/')), esc_html__('Docs', 'kb-antora-importer')), + ]; + $path = $base; + + foreach ($parts as $label => $slug) { + if ('' === (string) $slug) { + $items[] = esc_html((string) $label); + continue; + } + + $path .= '/' . trim((string) $slug, '/'); + $items[] = sprintf('%s', esc_url(home_url('/' . $path . '/')), esc_html((string) $label)); + } + + return ''; + } +} diff --git a/kb-antora-importer/includes/Frontend/Router.php b/kb-antora-importer/includes/Frontend/Router.php new file mode 100644 index 0000000..2e24f31 --- /dev/null +++ b/kb-antora-importer/includes/Frontend/Router.php @@ -0,0 +1,385 @@ +routeFromRequestUri(); + + if (! $route) { + return $queryVars; + } + + $queryVars = [ + 'kb_antora_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_antora_route'); + $requestRoute = []; + + if (! $route) { + $requestRoute = $this->routeFromRequestUri(); + if (! $requestRoute) { + return; + } + $route = $requestRoute['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))), + ]; + } + + 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, + ]); + } + + 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->captureProduct((string) $atts['product']); + } + + private function renderIndex(): void + { + echo $this->captureIndex(); + } + + private function captureIndex(): string + { + return (new TemplateLoader())->capture('documentation-index', [ + 'products' => self::productsWithVersions(), + 'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'), + 'url_builder' => UrlBuilder::class, + ]); + } + + private function renderProduct(string $productSlug): void + { + echo $this->captureProduct($productSlug); + } + + private function captureProduct(string $productSlug): string + { + $product = get_term_by('slug', $productSlug, 'kb_product'); + if (! $product) { + return $this->capture404(); + } + + $versions = $this->versionsForProduct($productSlug); + + return (new TemplateLoader())->capture('product', [ + 'product' => $product, + '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->captureVersion($productSlug, $versionSlug); + } + + private function captureVersion(string $productSlug, string $versionSlug): string + { + $product = get_term_by('slug', $productSlug, 'kb_product'); + $version = get_term_by('slug', $versionSlug, 'kb_version'); + + if (! $product || ! $version) { + return $this->capture404(); + } + + $landing = $this->landingPageForVersion($productSlug, $versionSlug); + if ($landing) { + return $this->captureDocPage($landing, $productSlug, $versionSlug); + } + + return (new TemplateLoader())->capture('version', [ + 'product' => $product, + 'version' => $version, + 'pages' => $this->pagesForVersion($productSlug, $versionSlug), + '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->capturePage($productSlug, $versionSlug, $pageSlug); + } + + private function capturePage(string $productSlug, string $versionSlug, string $pageSlug): string + { + $post = (new PageRepository())->findFrontendPage($productSlug, $versionSlug, $pageSlug); + + if (! $post && '' === $pageSlug) { + $post = (new PageRepository())->findFrontendPage($productSlug, $versionSlug, 'index'); + } + + if (! $post) { + return $this->capture404(); + } + + return $this->captureDocPage($post, $productSlug, $versionSlug); + } + + 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): string + { + $product = get_term_by('slug', $productSlug, 'kb_product'); + $version = get_term_by('slug', $versionSlug, 'kb_version'); + $navTree = json_decode((string) get_post_meta($post->ID, '_kb_nav_tree', true), true); + + return (new TemplateLoader())->capture('page', [ + 'post' => $post, + 'product' => $product, + '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, + 'url_builder' => UrlBuilder::class, + ]); + } + + private function captureRoute(string $route, string $productSlug = '', string $versionSlug = '', string $pageSlug = ''): string + { + return 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(), + }; + } + + private function render404(): void + { + status_header(404); + (new TemplateLoader())->render('search', [ + 'title' => __('Documentation page not found.', 'kb-antora-importer'), + 'results' => [], + 'query' => '', + ]); + } + + private function capture404(): string + { + status_header(404); + return (new TemplateLoader())->capture('search', [ + 'title' => __('Documentation page not found.', 'kb-antora-importer'), + 'results' => [], + 'query' => '', + ]); + } + + public static function productsWithVersions(): array + { + $products = get_terms(['taxonomy' => 'kb_product', 'hide_empty' => false]); + $items = []; + + if (is_wp_error($products)) { + return []; + } + + foreach ($products as $product) { + $items[] = [ + 'term' => $product, + 'versions' => (new self())->versionsForProduct($product->slug), + ]; + } + + return $items; + } + + private function versionsForProduct(string $productSlug): array + { + $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' => $productSlug], + ], + ]); + $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 + { + $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' => $productSlug], + ['taxonomy' => 'kb_version', 'field' => 'slug', 'terms' => $versionSlug], + ], + ]); + + return $query->posts; + } + + private function landingPageForVersion(string $productSlug, string $versionSlug): ?\WP_Post + { + $repository = new PageRepository(); + $landing = $repository->findFrontendPage($productSlug, $versionSlug, ''); + + if ($landing) { + return $landing; + } + + $pages = $this->pagesForVersion($productSlug, $versionSlug); + + return $pages[0] ?? null; + } +} diff --git a/kb-antora-importer/includes/Frontend/SearchController.php b/kb-antora-importer/includes/Frontend/SearchController.php new file mode 100644 index 0000000..966d108 --- /dev/null +++ b/kb-antora-importer/includes/Frontend/SearchController.php @@ -0,0 +1,69 @@ +canView()) { + return ''; + } + + $query = sanitize_text_field(wp_unslash((string) ($_GET['kbq'] ?? ''))); + $results = $query ? self::search($query, sanitize_text_field(wp_unslash((string) ($_GET['product'] ?? ''))), sanitize_text_field(wp_unslash((string) ($_GET['version'] ?? '')))) : []; + + return (new TemplateLoader())->capture('search', [ + 'title' => __('Search Documentation', 'kb-antora-importer'), + 'query' => $query, + 'results' => $results, + ]); + } + + public static function restSearch(\WP_REST_Request $request): \WP_REST_Response + { + if (! (new AccessController())->canView()) { + return new \WP_REST_Response(['results' => []], 403); + } + + $query = sanitize_text_field((string) $request->get_param('q')); + $product = sanitize_title((string) $request->get_param('product')); + $version = sanitize_title((string) $request->get_param('version')); + + return new \WP_REST_Response(['results' => self::search($query, $product, $version)]); + } + + private static function search(string $query, string $productSlug = '', string $versionSlug = ''): array + { + if ('' === $query) { + return []; + } + + $taxQuery = []; + if ($productSlug) { + $taxQuery[] = ['taxonomy' => 'kb_product', 'field' => 'slug', 'terms' => $productSlug]; + } + if ($versionSlug) { + $taxQuery[] = ['taxonomy' => 'kb_version', 'field' => 'slug', 'terms' => $versionSlug]; + } + if (count($taxQuery) > 1) { + $taxQuery['relation'] = 'AND'; + } + + $args = [ + 'post_type' => 'kb_doc_page', + 'post_status' => 'publish', + 's' => $query, + 'posts_per_page' => 20, + ]; + + if ($taxQuery) { + $args['tax_query'] = $taxQuery; + } + + return (new \WP_Query($args))->posts; + } +} diff --git a/kb-antora-importer/includes/Frontend/TemplateLoader.php b/kb-antora-importer/includes/Frontend/TemplateLoader.php new file mode 100644 index 0000000..1fffe23 --- /dev/null +++ b/kb-antora-importer/includes/Frontend/TemplateLoader.php @@ -0,0 +1,28 @@ +render($template, $vars); + return (string) ob_get_clean(); + } +} diff --git a/kb-antora-importer/includes/Frontend/UrlBuilder.php b/kb-antora-importer/includes/Frontend/UrlBuilder.php new file mode 100644 index 0000000..85cbca3 --- /dev/null +++ b/kb-antora-importer/includes/Frontend/UrlBuilder.php @@ -0,0 +1,163 @@ + $route]; + + if ($productSlug) { + $args['kb_docs_product'] = $productSlug; + } + + if ($versionSlug) { + $args['kb_docs_version'] = $versionSlug; + } + + if ($pageSlug) { + $args['kb_docs_page'] = $pageSlug; + } + + return add_query_arg($args, self::$embedBaseUrl); + } + + $base = trim((string) Plugin::settings()['docs_base_slug'], '/') ?: 'docs'; + + if (self::supportsPrettyPermalinks()) { + $parts = array_filter([$base, $productSlug, $versionSlug, $pageSlug], static fn (string $part): bool => '' !== $part); + return home_url('/' . implode('/', array_map('rawurlencode', $parts)) . '/'); + } + + $args = ['kb_antora_route' => $route]; + + if ($productSlug) { + $args['kb_product_slug'] = $productSlug; + } + + if ($versionSlug) { + $args['kb_version_slug'] = $versionSlug; + } + + if ($pageSlug) { + $args['kb_page_slug'] = $pageSlug; + } + + return add_query_arg($args, home_url('/')); + } + + private static function supportsPrettyPermalinks(): bool + { + return '' !== (string) get_option('permalink_structure', ''); + } + + public static function rewriteHtml(string $html): string + { + if (! self::isEmbed()) { + return $html; + } + + return preg_replace_callback('/href=(["\'])([^"\']+)\1/i', static function (array $matches): string { + $url = html_entity_decode((string) $matches[2], ENT_QUOTES); + $replacement = self::rewriteUrl($url); + + if (! $replacement) { + return $matches[0]; + } + + return 'href=' . $matches[1] . esc_url($replacement) . $matches[1]; + }, $html) ?? $html; + } + + private static function rewriteUrl(string $url): string + { + $parts = wp_parse_url($url); + + if (! is_array($parts)) { + return ''; + } + + $query = []; + if (! empty($parts['query'])) { + wp_parse_str((string) $parts['query'], $query); + } + + if (! empty($query['kb_antora_route'])) { + return self::route( + sanitize_key((string) $query['kb_antora_route']), + sanitize_title((string) ($query['kb_product_slug'] ?? '')), + sanitize_title((string) ($query['kb_version_slug'] ?? '')), + sanitize_title((string) ($query['kb_page_slug'] ?? '')) + ); + } + + $base = trim((string) Plugin::settings()['docs_base_slug'], '/') ?: 'docs'; + $path = trim((string) ($parts['path'] ?? ''), '/'); + + if ($path === $base) { + return self::docsIndex(); + } + + if (! str_starts_with($path . '/', $base . '/')) { + return ''; + } + + $routeParts = array_values(array_filter(explode('/', substr($path, strlen($base))), static fn (string $part): bool => '' !== $part)); + + if (1 === count($routeParts)) { + return self::product(sanitize_title($routeParts[0])); + } + + if (2 === count($routeParts)) { + return self::version(sanitize_title($routeParts[0]), sanitize_title($routeParts[1])); + } + + return self::page( + sanitize_title($routeParts[0] ?? ''), + sanitize_title($routeParts[1] ?? ''), + sanitize_title(implode('/', array_slice($routeParts, 2))) + ); + } +} diff --git a/kb-antora-importer/includes/GitLab/GitLabBranch.php b/kb-antora-importer/includes/GitLab/GitLabBranch.php new file mode 100644 index 0000000..398c59b --- /dev/null +++ b/kb-antora-importer/includes/GitLab/GitLabBranch.php @@ -0,0 +1,13 @@ +baseUrl = self::normalizeBaseUrl((string) ($settings['gitlab_base_url'] ?? '')); + $this->token = (string) ($settings['gitlab_token'] ?? ''); + $this->branchPattern = (string) ($settings['branch_pattern'] ?? '^v.*'); + } + + public static function normalizeBaseUrl(string $baseUrl): string + { + $baseUrl = rtrim(trim($baseUrl), '/'); + + if (preg_match('#/api/v4$#i', $baseUrl)) { + $baseUrl = (string) preg_replace('#/api/v4$#i', '', $baseUrl); + } + + return $baseUrl; + } + + public function getGroup(string $group): array|\WP_Error + { + return $this->request('GET', '/groups/' . rawurlencode($group)); + } + + public function getProjects(string $group): array|\WP_Error + { + return $this->requestAll('/groups/' . rawurlencode($group) . '/projects', [ + 'include_subgroups' => 'true', + 'simple' => 'true', + 'order_by' => 'path', + 'sort' => 'asc', + ]); + } + + public function getProject(string $projectId): array|\WP_Error + { + return $this->request('GET', '/projects/' . rawurlencode($projectId)); + } + + public function getBranches(string $projectId): array|\WP_Error + { + return $this->requestAll('/projects/' . rawurlencode($projectId) . '/repository/branches', []); + } + + public function getDocumentationBranches(string $projectId): array|\WP_Error + { + $branches = $this->getBranches($projectId); + + if (is_wp_error($branches)) { + return $branches; + } + + $pattern = '/' . str_replace('/', '\/', $this->branchPattern) . '/'; + + return array_values(array_filter($branches, static function (array $branch) use ($pattern): bool { + return isset($branch['name']) && @preg_match($pattern, (string) $branch['name']); + })); + } + + public function getFileRaw(string $projectId, string $path, string $ref): string|\WP_Error + { + $response = $this->rawRequest('/projects/' . rawurlencode($projectId) . '/repository/files/' . rawurlencode($path) . '/raw', [ + 'ref' => $ref, + ]); + + if (is_wp_error($response)) { + return $response; + } + + return wp_remote_retrieve_body($response); + } + + public function getTree(string $projectId, string $ref, string $path = '', bool $recursive = true): array|\WP_Error + { + return $this->requestAll('/projects/' . rawurlencode($projectId) . '/repository/tree', [ + 'ref' => $ref, + 'path' => $path, + 'recursive' => $recursive ? 'true' : 'false', + ]); + } + + private function requestAll(string $endpoint, array $query): array|\WP_Error + { + $page = 1; + $items = []; + + do { + $response = $this->rawRequest($endpoint, array_merge($query, [ + 'per_page' => '100', + 'page' => (string) $page, + ])); + + if (is_wp_error($response)) { + return $response; + } + + $decoded = json_decode(wp_remote_retrieve_body($response), true); + + if (! is_array($decoded)) { + return new \WP_Error('kb_gitlab_invalid_json', __('GitLab returned invalid JSON.', 'kb-antora-importer')); + } + + $items = array_merge($items, $decoded); + $next = wp_remote_retrieve_header($response, 'x-next-page'); + $page = $next ? (int) $next : 0; + } while ($page > 0); + + return $items; + } + + private function request(string $method, string $endpoint, array $query = []): array|\WP_Error + { + $response = $this->rawRequest($endpoint, $query, $method); + + if (is_wp_error($response)) { + return $response; + } + + $decoded = json_decode(wp_remote_retrieve_body($response), true); + + if (! is_array($decoded)) { + return new \WP_Error('kb_gitlab_invalid_json', __('GitLab returned invalid JSON.', 'kb-antora-importer')); + } + + return $decoded; + } + + private function rawRequest(string $endpoint, array $query = [], string $method = 'GET'): array|\WP_Error + { + if (! $this->baseUrl || ! $this->token) { + return new \WP_Error('kb_gitlab_missing_settings', __('GitLab base URL or token is missing.', 'kb-antora-importer')); + } + + $url = $this->baseUrl . '/api/v4' . $endpoint; + + if ($query) { + $url = add_query_arg($query, $url); + } + + $response = wp_remote_request($url, [ + 'method' => $method, + 'timeout' => 30, + 'headers' => [ + 'PRIVATE-TOKEN' => $this->token, + 'Accept' => 'application/json', + ], + ]); + + if (is_wp_error($response)) { + return $response; + } + + $code = (int) wp_remote_retrieve_response_code($response); + + if ($code >= 200 && $code < 300) { + return $response; + } + + $body = wp_strip_all_tags(wp_remote_retrieve_body($response)); + $body = trim(preg_replace('/\s+/', ' ', $body) ?? $body); + $body = substr($body, 0, 300); + $retryAfter = wp_remote_retrieve_header($response, 'retry-after'); + $message = sprintf( + /* translators: %d is an HTTP status code. */ + __('GitLab API request failed with HTTP %d.', 'kb-antora-importer'), + $code + ); + + if (503 === $code) { + $message .= ' ' . __('The GitLab server or a proxy returned Service Unavailable. Check whether GitLab is reachable from the WordPress server and whether the Base URL points to the GitLab root, not to /api/v4.', 'kb-antora-importer'); + } + + return new \WP_Error( + 'kb_gitlab_http_' . $code, + $message, + [ + 'status' => $code, + 'url' => esc_url_raw($url), + 'retry_after' => $retryAfter ? (string) $retryAfter : '', + 'response_excerpt' => $body, + ] + ); + } +} diff --git a/kb-antora-importer/includes/GitLab/GitLabProject.php b/kb-antora-importer/includes/GitLab/GitLabProject.php new file mode 100644 index 0000000..611b59b --- /dev/null +++ b/kb-antora-importer/includes/GitLab/GitLabProject.php @@ -0,0 +1,14 @@ + current_time('mysql'), + 'level' => $level, + 'message' => self::sanitize($message), + ]; + + if (count($logs) > self::LIMIT) { + $logs = array_slice($logs, -self::LIMIT); + } + + update_option(self::OPTION, $logs, false); + } + + private static function sanitize(string $message): string + { + $message = preg_replace('/([?&](?:private_)?token=)[^&\s]+/i', '$1[redacted]', $message) ?? $message; + $message = preg_replace('/(PRIVATE-TOKEN|Authorization):\s*\S+/i', '$1: [redacted]', $message) ?? $message; + + return sanitize_text_field($message); + } +} diff --git a/kb-antora-importer/includes/Import/ImportManager.php b/kb-antora-importer/includes/Import/ImportManager.php new file mode 100644 index 0000000..8069cc8 --- /dev/null +++ b/kb-antora-importer/includes/Import/ImportManager.php @@ -0,0 +1,382 @@ +settings = Plugin::settings(); + $this->client = new GitLabClient($this->settings); + $this->antora = new AntoraParser(); + $this->yamlReader = new AntoraYamlReader(); + $this->navParser = new AntoraNavParser(); + $this->pages = new PageRepository(); + $this->products = new ProductRepository(); + $this->versions = new VersionRepository(); + } + + public function syncAll(bool $dryRun = false): \WP_REST_Response + { + ImportLogger::info($dryRun ? 'Dry run started.' : 'Synchronization started.'); + + $group = $this->client->getGroup((string) $this->settings['gitlab_group']); + if (is_wp_error($group)) { + ImportLogger::error('Group lookup failed: ' . $group->get_error_message()); + return new \WP_REST_Response(['success' => false, 'message' => $group->get_error_message()], 500); + } + + $projects = $this->client->getProjects((string) ($group['id'] ?? $this->settings['gitlab_group'])); + if (is_wp_error($projects)) { + ImportLogger::error('Project lookup failed: ' . $projects->get_error_message()); + return new \WP_REST_Response(['success' => false, 'message' => $projects->get_error_message()], 500); + } + + $stats = ['projects' => 0, 'branches' => 0, 'pages' => 0]; + + foreach ($projects as $project) { + $stats['projects']++; + $result = $this->syncProjectData($project, $dryRun); + $stats['branches'] += $result['branches']; + $stats['pages'] += $result['pages']; + } + + update_option('kb_antora_importer_last_sync', current_time('mysql'), false); + ImportLogger::info('Synchronization completed.'); + + return new \WP_REST_Response(['success' => true, 'stats' => $stats]); + } + + public function syncProject(string $projectId, bool $dryRun = false): \WP_REST_Response + { + if (! $projectId) { + return new \WP_REST_Response(['success' => false, 'message' => 'Project ID missing.'], 400); + } + + $project = $this->client->getProject($projectId); + if (is_wp_error($project)) { + ImportLogger::error('Project lookup failed: ' . $project->get_error_message()); + return new \WP_REST_Response(['success' => false, 'message' => $project->get_error_message()], 500); + } + + $result = $this->syncProjectData($project, $dryRun); + update_option('kb_antora_importer_last_sync', current_time('mysql'), false); + + return new \WP_REST_Response(['success' => true, 'stats' => $result]); + } + + private function syncProjectData(array $project, bool $dryRun): array + { + $projectId = (string) ($project['id'] ?? ''); + $projectPath = (string) ($project['path_with_namespace'] ?? $project['path'] ?? $projectId); + + if (! $projectId) { + return ['branches' => 0, 'pages' => 0]; + } + + ImportLogger::info('Project found: ' . $projectPath); + $branches = $this->client->getDocumentationBranches($projectId); + + if (is_wp_error($branches)) { + ImportLogger::error('Branch lookup failed for ' . $projectPath . ': ' . $branches->get_error_message()); + return ['branches' => 0, 'pages' => 0]; + } + + $stats = ['branches' => 0, 'pages' => 0]; + + foreach ($branches as $branch) { + $stats['branches']++; + $stats['pages'] += $this->syncBranch($project, $branch, $dryRun); + } + + return $stats; + } + + private function syncBranch(array $project, array $branch, bool $dryRun): int + { + $projectId = (string) ($project['id'] ?? ''); + $projectPath = (string) ($project['path_with_namespace'] ?? $project['path'] ?? $projectId); + $branchName = (string) ($branch['name'] ?? ''); + $commitSha = (string) ($branch['commit']['id'] ?? ''); + + if (! $branchName) { + return 0; + } + + ImportLogger::info('Branch found: ' . $projectPath . '@' . $branchName); + + $antoraYaml = $this->client->getFileRaw($projectId, 'antora.yml', $branchName); + if (is_wp_error($antoraYaml)) { + ImportLogger::warning('antora.yml missing or unreadable for ' . $projectPath . '@' . $branchName . '. Branch skipped.'); + return 0; + } + + $component = $this->yamlReader->parse($antoraYaml); + $productName = $component['title'] ?: $component['name'] ?: (string) ($project['name'] ?? $projectPath); + $productSlug = sanitize_title($component['name'] ?: ($project['path'] ?? $productName)); + $version = $component['version'] ?: ltrim($branchName, 'v'); + $versionSlug = sanitize_title($version); + $productTermId = $this->products->ensure($productName, $productSlug); + $versionTermId = $this->versions->ensure($version); + + $tree = $this->client->getTree($projectId, $branchName); + if (is_wp_error($tree)) { + ImportLogger::error('Repository tree failed for ' . $projectPath . '@' . $branchName . ': ' . $tree->get_error_message()); + return 0; + } + + $navTree = $this->loadNavigation($projectId, $branchName, $component); + $imageMap = $this->importImages($projectId, $branchName, $tree, $dryRun); + $pagePaths = array_values(array_filter(array_map(static fn (array $item): string => (string) ($item['path'] ?? ''), $tree), static fn (string $path): bool => (bool) preg_match('#^modules/[^/]+/pages/.+\.adoc$#', $path))); + + if (! $this->navHasTargets($navTree)) { + $navTree = $this->navTreeFromPages($pagePaths); + ImportLogger::warning('Navigation had no linked pages; generated a fallback navigation from imported pages for ' . $projectPath . '@' . $branchName . '.'); + } + + $navFlat = $this->navParser->flatten($navTree); + $count = 0; + foreach ($pagePaths as $sourcePath) { + $content = $this->client->getFileRaw($projectId, $sourcePath, $branchName); + + if (is_wp_error($content)) { + ImportLogger::warning('Page unreadable: ' . $sourcePath); + continue; + } + + $module = $this->antora->moduleFromPath($sourcePath); + $pagePath = preg_replace('#^modules/[^/]+/pages/#', '', $sourcePath) ?: basename($sourcePath); + $pageSlug = $this->antora->pageSlugFromPath($sourcePath); + $title = $this->extractTitle($content, $pagePath); + $navOrder = $this->navOrder($navFlat, basename($sourcePath)); + $renderer = new AsciiDocRenderer(); + $html = $renderer->render($content, [ + 'base_slug' => $this->settings['docs_base_slug'], + 'product_slug' => $productSlug, + 'version_slug' => $versionSlug, + 'images' => $imageMap, + 'lightbox' => '1' === $this->settings['image_lightbox'], + ]); + + $saved = $this->pages->save([ + 'project_id' => $projectId, + 'project_path' => $projectPath, + 'branch' => $branchName, + 'commit_sha' => $commitSha, + 'component' => $component['name'] ?: $productSlug, + 'component_title' => $productName, + 'version' => $version, + 'module' => $module, + 'page_path' => $pagePath, + 'source_path' => $sourcePath, + 'checksum' => Checksum::content($content), + 'title' => $title, + 'html' => $html, + 'nav_order' => $navOrder, + 'parent_page_path' => '', + 'product_slug' => $productSlug, + 'version_term_id' => $versionTermId, + 'product_term_id' => $productTermId, + 'version_slug' => $versionSlug, + 'page_slug' => $pageSlug, + 'nav_tree' => $navTree, + 'renderer_version' => 'antora-shell-2', + ], $dryRun); + + if ($saved || $dryRun) { + $count++; + } + } + + return $count; + } + + private function loadNavigation(string $projectId, string $branchName, array $component): array + { + $navFiles = $component['nav'] ?: ['modules/ROOT/nav.adoc']; + $tree = []; + + foreach ($navFiles as $navFile) { + $content = $this->client->getFileRaw($projectId, $navFile, $branchName); + if (is_wp_error($content)) { + ImportLogger::warning('nav.adoc missing or unreadable: ' . $navFile); + continue; + } + + $tree = array_merge($tree, $this->navParser->parse($content)); + } + + return $tree; + } + + private function importImages(string $projectId, string $branchName, array $tree, bool $dryRun): array + { + $images = []; + $allowed = ['png', 'jpg', 'jpeg', 'gif', 'webp']; + + if ('1' === $this->settings['allow_svg']) { + $allowed[] = 'svg'; + } + + foreach ($tree as $item) { + $path = (string) ($item['path'] ?? ''); + if (! preg_match('#^modules/[^/]+/images/.+\.(' . implode('|', $allowed) . ')$#i', $path)) { + continue; + } + + $url = $dryRun ? '' : $this->importImage($projectId, $branchName, $path); + if ($url) { + $images[basename($path)] = $url; + $images[preg_replace('#^modules/[^/]+/images/#', '', $path) ?: basename($path)] = $url; + } + } + + return $images; + } + + private function navHasTargets(array $nodes): bool + { + foreach ($nodes as $node) { + if (! empty($node['target'])) { + return true; + } + + if ($this->navHasTargets((array) ($node['children'] ?? []))) { + return true; + } + } + + return false; + } + + private function navTreeFromPages(array $pagePaths): array + { + usort($pagePaths, static function (string $a, string $b): int { + $aBase = basename($a); + $bBase = basename($b); + + if ('index.adoc' === $aBase || 'dokumentation.adoc' === $aBase) { + return -1; + } + + if ('index.adoc' === $bBase || 'dokumentation.adoc' === $bBase) { + return 1; + } + + return strnatcasecmp($aBase, $bBase); + }); + + return array_map(static function (string $path): array { + $target = preg_replace('#^modules/[^/]+/pages/#', '', $path) ?: basename($path); + $title = preg_replace('/\.adoc$/', '', basename($path)) ?: basename($path); + $title = ucwords(str_replace(['-', '_'], ' ', $title)); + + if (in_array(strtolower($title), ['index', 'dokumentation'], true)) { + $title = __('Overview', 'kb-antora-importer'); + } + + return [ + 'title' => $title, + 'target' => $target, + 'children' => [], + ]; + }, $pagePaths); + } + + private function importImage(string $projectId, string $branchName, string $path): string + { + $assetKey = $projectId . ':' . $branchName . ':' . $path; + $existing = $this->findAttachmentByAssetKey($assetKey); + if ($existing) { + return wp_get_attachment_url($existing) ?: ''; + } + + $content = $this->client->getFileRaw($projectId, $path, $branchName); + if (is_wp_error($content)) { + ImportLogger::warning('Asset unreadable: ' . $path); + return ''; + } + + $upload = wp_upload_bits(basename($path), null, $content); + if (! empty($upload['error'])) { + ImportLogger::warning('Asset upload failed: ' . $path); + return ''; + } + + $filetype = wp_check_filetype($upload['file']); + $attachmentId = wp_insert_attachment([ + 'post_mime_type' => $filetype['type'] ?: 'application/octet-stream', + 'post_title' => sanitize_file_name(basename($path)), + 'post_status' => 'inherit', + ], $upload['file']); + + if (is_wp_error($attachmentId)) { + ImportLogger::warning('Attachment creation failed: ' . $path); + return ''; + } + + require_once ABSPATH . 'wp-admin/includes/image.php'; + $metadata = wp_generate_attachment_metadata((int) $attachmentId, $upload['file']); + wp_update_attachment_metadata((int) $attachmentId, $metadata); + update_post_meta((int) $attachmentId, '_kb_antora_asset_key', $assetKey); + update_post_meta((int) $attachmentId, '_kb_antora_asset_checksum', Checksum::content($content)); + ImportLogger::info('Asset imported: ' . $path); + + return wp_get_attachment_url((int) $attachmentId) ?: ''; + } + + private function findAttachmentByAssetKey(string $assetKey): int + { + $query = new \WP_Query([ + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'meta_key' => '_kb_antora_asset_key', + 'meta_value' => $assetKey, + ]); + + return (int) ($query->posts[0] ?? 0); + } + + private function extractTitle(string $content, string $fallback): string + { + if (preg_match('/^=\s+(.+)$/m', $content, $matches)) { + return trim($matches[1]); + } + + return ucwords(str_replace(['-', '_', '.adoc'], [' ', ' ', ''], basename($fallback))); + } + + private function navOrder(array $navFlat, string $basename): int + { + foreach ($navFlat as $index => $item) { + if ($basename === basename((string) ($item['target'] ?? ''))) { + return $index + 1; + } + } + + return 9999; + } +} diff --git a/kb-antora-importer/includes/Plugin.php b/kb-antora-importer/includes/Plugin.php new file mode 100644 index 0000000..7fe531c --- /dev/null +++ b/kb-antora-importer/includes/Plugin.php @@ -0,0 +1,231 @@ +boot(); + } + + public static function activate(): void + { + self::instance()->registerContentTypes(); + (new Router())->addRewriteRules(); + self::grantCapabilities(); + self::ensureDefaultSettings(); + flush_rewrite_rules(); + } + + public static function deactivate(): void + { + wp_clear_scheduled_hook('kb_antora_importer_cron_sync'); + flush_rewrite_rules(); + } + + public function registerContentTypes(): void + { + register_post_type('kb_doc_page', [ + 'labels' => [ + 'name' => __('Documentation Pages', 'kb-antora-importer'), + 'singular_name' => __('Documentation Page', 'kb-antora-importer'), + ], + 'public' => false, + 'show_ui' => true, + 'show_in_menu' => 'kb-antora-importer', + 'show_in_rest' => true, + 'supports' => ['title', 'editor', 'excerpt', 'custom-fields'], + 'capability_type' => 'post', + ]); + + register_taxonomy('kb_product', ['kb_doc_page'], [ + 'labels' => [ + 'name' => __('Products', 'kb-antora-importer'), + 'singular_name' => __('Product', 'kb-antora-importer'), + ], + 'public' => false, + 'show_ui' => true, + 'show_in_rest' => true, + 'hierarchical' => false, + 'rewrite' => false, + ]); + + register_taxonomy('kb_version', ['kb_doc_page'], [ + 'labels' => [ + 'name' => __('Versions', 'kb-antora-importer'), + 'singular_name' => __('Version', 'kb-antora-importer'), + ], + 'public' => false, + 'show_ui' => true, + 'show_in_rest' => true, + 'hierarchical' => false, + 'rewrite' => false, + ]); + + register_taxonomy('kb_component', ['kb_doc_page'], [ + 'labels' => [ + 'name' => __('Components', 'kb-antora-importer'), + 'singular_name' => __('Component', 'kb-antora-importer'), + ], + 'public' => false, + 'show_ui' => true, + 'show_in_rest' => true, + 'hierarchical' => false, + 'rewrite' => false, + ]); + } + + public function registerAdminPages(): void + { + add_menu_page( + __('Knowledgebase', 'kb-antora-importer'), + __('Knowledgebase', 'kb-antora-importer'), + 'manage_kb_docs', + 'kb-antora-importer', + [StatusPage::class, 'render'], + 'dashicons-welcome-learn-more', + 58 + ); + + add_submenu_page('kb-antora-importer', __('Overview', 'kb-antora-importer'), __('Overview', 'kb-antora-importer'), 'manage_kb_docs', 'kb-antora-importer', [StatusPage::class, 'render']); + add_submenu_page('kb-antora-importer', __('Synchronization', 'kb-antora-importer'), __('Synchronization', 'kb-antora-importer'), 'sync_kb_docs', 'kb-antora-sync', [SyncPage::class, 'render']); + add_submenu_page('kb-antora-importer', __('Settings', 'kb-antora-importer'), __('Settings', 'kb-antora-importer'), 'manage_kb_docs', 'kb-antora-settings', [SettingsPage::class, 'render']); + } + + public function registerRestRoutes(): void + { + register_rest_route('kb-antora/v1', '/status', [ + 'methods' => 'GET', + 'callback' => [StatusPage::class, 'restStatus'], + 'permission_callback' => static fn (): bool => current_user_can('manage_kb_docs'), + ]); + + register_rest_route('kb-antora/v1', '/sync', [ + 'methods' => 'POST', + 'callback' => static fn (\WP_REST_Request $request): \WP_REST_Response => (new ImportManager())->syncAll((bool) $request->get_param('dry_run')), + 'permission_callback' => static fn (): bool => current_user_can('sync_kb_docs'), + ]); + + register_rest_route('kb-antora/v1', '/sync/project', [ + 'methods' => 'POST', + 'callback' => static fn (\WP_REST_Request $request): \WP_REST_Response => (new ImportManager())->syncProject((string) $request->get_param('project_id'), (bool) $request->get_param('dry_run')), + 'permission_callback' => static fn (): bool => current_user_can('sync_kb_docs'), + ]); + + register_rest_route('kb-antora/v1', '/search', [ + 'methods' => 'GET', + 'callback' => [SearchController::class, 'restSearch'], + 'permission_callback' => '__return_true', + ]); + + register_rest_route('kb-antora/v1', '/gitlab-webhook', [ + 'methods' => 'POST', + 'callback' => static fn (): \WP_REST_Response => new \WP_REST_Response(['queued' => false, 'message' => 'Webhook endpoint is reserved for a later event-driven sync implementation.']), + 'permission_callback' => static fn (): bool => current_user_can('sync_kb_docs'), + ]); + } + + public function registerShortcodes(): void + { + add_shortcode('kb_docs_index', [Router::class, 'shortcodeDocsIndex']); + add_shortcode('kb_docs', [Router::class, 'shortcodeDocsApp']); + add_shortcode('kb_product_index', [Router::class, 'shortcodeProductIndex']); + add_shortcode('kb_search', [SearchController::class, 'shortcodeSearch']); + } + + public function addCronSchedules(array $schedules): array + { + $schedules['kb_antora_weekly'] = [ + 'interval' => WEEK_IN_SECONDS, + 'display' => __('Weekly', 'kb-antora-importer'), + ]; + + return $schedules; + } + + public function runCronSync(): void + { + (new ImportManager())->syncAll(false); + } + + public function enqueueFrontendAssets(): void + { + wp_enqueue_style('kb-antora-frontend', KB_ANTORA_IMPORTER_URL . 'assets/css/frontend.css', [], KB_ANTORA_IMPORTER_VERSION); + wp_enqueue_script('kb-antora-frontend', KB_ANTORA_IMPORTER_URL . 'assets/js/frontend.js', [], KB_ANTORA_IMPORTER_VERSION, true); + } + + public static function settings(): array + { + return wp_parse_args((array) get_option('kb_antora_importer_settings', []), Settings::defaults()); + } + + public static function syncCronSchedule(?array $settings = null): void + { + $settings = $settings ?: self::settings(); + wp_clear_scheduled_hook('kb_antora_importer_cron_sync'); + + if ('disabled' === $settings['cron_interval']) { + return; + } + + $schedule = match ($settings['cron_interval']) { + 'hourly' => 'hourly', + 'daily' => 'daily', + 'weekly' => 'kb_antora_weekly', + default => '', + }; + + if ($schedule && ! wp_next_scheduled('kb_antora_importer_cron_sync')) { + wp_schedule_event(time() + HOUR_IN_SECONDS, $schedule, 'kb_antora_importer_cron_sync'); + } + } + + private static function ensureDefaultSettings(): void + { + if (false === get_option('kb_antora_importer_settings', false)) { + add_option('kb_antora_importer_settings', Settings::defaults(), '', false); + } + } + + private static function grantCapabilities(): void + { + $role = get_role('administrator'); + + if (! $role) { + return; + } + + foreach (['manage_kb_docs', 'view_kb_docs', 'sync_kb_docs'] as $capability) { + $role->add_cap($capability); + } + } +} diff --git a/kb-antora-importer/includes/Repository/PageRepository.php b/kb-antora-importer/includes/Repository/PageRepository.php new file mode 100644 index 0000000..75f2287 --- /dev/null +++ b/kb-antora-importer/includes/Repository/PageRepository.php @@ -0,0 +1,130 @@ + 'kb_doc_page', + 'post_status' => ['publish', 'draft', 'private'], + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => '_kb_gitlab_project_id', + 'value' => $projectId, + ], + [ + 'key' => '_kb_gitlab_branch', + 'value' => $branch, + ], + [ + 'key' => '_kb_antora_source_path', + 'value' => $sourcePath, + ], + ], + ]); + + return (int) ($query->posts[0] ?? 0); + } + + public function save(array $data, bool $dryRun = false): int + { + $existingId = $this->findBySource($data['project_id'], $data['branch'], $data['source_path']); + + if ($existingId) { + $oldChecksum = (string) get_post_meta($existingId, '_kb_page_checksum', true); + $oldRendererVersion = (string) get_post_meta($existingId, '_kb_renderer_version', true); + if ($oldChecksum === $data['checksum'] && $oldRendererVersion === $data['renderer_version']) { + ImportLogger::info('Page unchanged: ' . $data['source_path']); + return $existingId; + } + } + + if ($dryRun) { + ImportLogger::info(($existingId ? 'Would update page: ' : 'Would import page: ') . $data['source_path']); + return $existingId; + } + + $postData = [ + 'post_type' => 'kb_doc_page', + 'post_status' => 'publish', + 'post_title' => $data['title'], + 'post_name' => $data['page_slug'] ?: 'index', + 'post_content' => $data['html'], + 'post_excerpt' => wp_trim_words(wp_strip_all_tags($data['html']), 35), + ]; + + if ($existingId) { + $postData['ID'] = $existingId; + $postId = wp_update_post(wp_slash($postData), true); + } else { + $postId = wp_insert_post(wp_slash($postData), true); + } + + if (is_wp_error($postId)) { + ImportLogger::error('Failed to save page ' . $data['source_path'] . ': ' . $postId->get_error_message()); + return 0; + } + + $meta = [ + '_kb_gitlab_project_id' => $data['project_id'], + '_kb_gitlab_project_path' => $data['project_path'], + '_kb_gitlab_branch' => $data['branch'], + '_kb_gitlab_commit_sha' => $data['commit_sha'], + '_kb_antora_component' => $data['component'], + '_kb_antora_component_title' => $data['component_title'], + '_kb_antora_version' => $data['version'], + '_kb_antora_module' => $data['module'], + '_kb_antora_page_path' => $data['page_path'], + '_kb_antora_source_path' => $data['source_path'], + '_kb_page_checksum' => $data['checksum'], + '_kb_last_imported_at' => current_time('mysql'), + '_kb_nav_order' => (string) $data['nav_order'], + '_kb_parent_page_path' => $data['parent_page_path'], + '_kb_product_slug' => $data['product_slug'], + '_kb_version_slug' => sanitize_title($data['version']), + '_kb_page_slug' => $data['page_slug'], + '_kb_nav_tree' => wp_json_encode($data['nav_tree']), + '_kb_renderer_version' => $data['renderer_version'], + ]; + + foreach ($meta as $key => $value) { + update_post_meta((int) $postId, $key, $value); + } + + wp_set_object_terms((int) $postId, [$data['product_term_id']], 'kb_product'); + wp_set_object_terms((int) $postId, [$data['version_term_id']], 'kb_version'); + wp_set_object_terms((int) $postId, [$data['component']], 'kb_component'); + + ImportLogger::info(($existingId ? 'Page updated: ' : 'Page imported: ') . $data['source_path']); + + return (int) $postId; + } + + public function findFrontendPage(string $productSlug, string $versionSlug, string $pageSlug): ?\WP_Post + { + $pageSlug = $pageSlug ?: ''; + $query = new \WP_Query([ + 'post_type' => 'kb_doc_page', + 'post_status' => 'publish', + 'posts_per_page' => 1, + 'no_found_rows' => true, + 'meta_query' => [ + 'relation' => 'AND', + ['key' => '_kb_product_slug', 'value' => $productSlug], + ['key' => '_kb_version_slug', 'value' => $versionSlug], + ['key' => '_kb_page_slug', 'value' => $pageSlug], + ], + ]); + + return $query->have_posts() ? $query->posts[0] : null; + } +} diff --git a/kb-antora-importer/includes/Repository/ProductRepository.php b/kb-antora-importer/includes/Repository/ProductRepository.php new file mode 100644 index 0000000..782c6a8 --- /dev/null +++ b/kb-antora-importer/includes/Repository/ProductRepository.php @@ -0,0 +1,19 @@ + $slug]); + } + + return is_wp_error($term) ? 0 : (int) ($term['term_id'] ?? $term); + } +} diff --git a/kb-antora-importer/includes/Repository/VersionRepository.php b/kb-antora-importer/includes/Repository/VersionRepository.php new file mode 100644 index 0000000..864c8e1 --- /dev/null +++ b/kb-antora-importer/includes/Repository/VersionRepository.php @@ -0,0 +1,18 @@ + sanitize_title($version)]); + } + + return is_wp_error($term) ? 0 : (int) ($term['term_id'] ?? $term); + } +} diff --git a/kb-antora-importer/includes/Settings.php b/kb-antora-importer/includes/Settings.php new file mode 100644 index 0000000..7f40bec --- /dev/null +++ b/kb-antora-importer/includes/Settings.php @@ -0,0 +1,24 @@ + '', + 'gitlab_token' => '', + 'gitlab_group' => 'knowledgebase', + 'branch_pattern' => '^v.*', + 'docs_base_slug' => 'docs', + 'renderer_mode' => 'php', + 'asciidoctor_path' => 'asciidoctor', + 'image_lightbox' => '1', + 'public_docs' => '0', + 'cron_interval' => 'disabled', + 'allow_svg' => '0', + ]; + } +} diff --git a/kb-antora-importer/kb-antora-importer.php b/kb-antora-importer/kb-antora-importer.php new file mode 100644 index 0000000..dde3696 --- /dev/null +++ b/kb-antora-importer/kb-antora-importer.php @@ -0,0 +1,42 @@ +boot(); +}); diff --git a/kb-antora-importer/readme.md b/kb-antora-importer/readme.md new file mode 100644 index 0000000..69e6c7e --- /dev/null +++ b/kb-antora-importer/readme.md @@ -0,0 +1,18 @@ +# KB Antora Importer + +WordPress plugin MVP for importing GitLab/Antora based AsciiDoc documentation into a versioned customer portal knowledgebase. + +## Features + +- GitLab settings and connection test in the WordPress admin. +- Custom post type `kb_doc_page`. +- Taxonomies for products, versions and components. +- Manual GitLab sync for projects, branches, `antora.yml`, `nav.adoc`, pages and images. +- Basic PHP AsciiDoc renderer for headings, paragraphs, lists, code blocks, admonitions, links, xrefs and images. +- Frontend routes under `/docs/`. +- Shortcodes: `[kb_docs_index]`, `[kb_product_index product="..."]`, `[kb_search]`. +- Import logs without exposing secrets. + +## Notes + +This is an MVP. It intentionally does not rebuild Antora completely. The importer stores rendered content in WordPress so frontend requests do not call GitLab. diff --git a/kb-antora-importer/templates/documentation-index.php b/kb-antora-importer/templates/documentation-index.php new file mode 100644 index 0000000..fe4987f --- /dev/null +++ b/kb-antora-importer/templates/documentation-index.php @@ -0,0 +1,22 @@ + +
    +

    + +
    + + +
    +

    name); ?>

    + + + +
    + +
    +
    diff --git a/kb-antora-importer/templates/page.php b/kb-antora-importer/templates/page.php new file mode 100644 index 0000000..267ffa3 --- /dev/null +++ b/kb-antora-importer/templates/page.php @@ -0,0 +1,56 @@ +'; + foreach ($nodes as $node) { + $target = (string) ($node['target'] ?? ''); + $label = (string) ($node['title'] ?? ''); + $href = ''; + + if ($target) { + $slug = preg_replace('/\.adoc(#.+)?$/', '', basename($target)) ?: basename($target); + $slug = 'index' === $slug ? '' : 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 '
'; +}; +?> +
+ + +
diff --git a/kb-antora-importer/templates/product.php b/kb-antora-importer/templates/product.php new file mode 100644 index 0000000..2b88fbd --- /dev/null +++ b/kb-antora-importer/templates/product.php @@ -0,0 +1,16 @@ + +
+ +

name); ?>

+

+ +
diff --git a/kb-antora-importer/templates/search.php b/kb-antora-importer/templates/search.php new file mode 100644 index 0000000..7a26aa0 --- /dev/null +++ b/kb-antora-importer/templates/search.php @@ -0,0 +1,26 @@ + + diff --git a/kb-antora-importer/templates/version.php b/kb-antora-importer/templates/version.php new file mode 100644 index 0000000..b1c3091 --- /dev/null +++ b/kb-antora-importer/templates/version.php @@ -0,0 +1,17 @@ + +
+ +

name . ' ' . $version->name); ?>

+
    + + ID, '_kb_page_slug', true); ?> +
  • + +
+
diff --git a/kb-antora-importer/uninstall.php b/kb-antora-importer/uninstall.php new file mode 100644 index 0000000..ae20c70 --- /dev/null +++ b/kb-antora-importer/uninstall.php @@ -0,0 +1,11 @@ +