diff --git a/kb-antora-importer.zip b/kb-antora-importer.zip new file mode 100644 index 0000000..f2473fa Binary files /dev/null and b/kb-antora-importer.zip differ diff --git a/kb-antora-importer/assets/css/frontend.css b/kb-antora-importer/assets/css/frontend.css new file mode 100644 index 0000000..0f8ee0e --- /dev/null +++ b/kb-antora-importer/assets/css/frontend.css @@ -0,0 +1,210 @@ +.kb-docs-wrap { + --kb-surface: var(--wp--preset--color--base, #ffffff); + --kb-surface-muted: color-mix(in srgb, var(--kb-surface) 88%, #eef3f8); + --kb-text: var(--wp--preset--color--contrast, #1f2933); + --kb-muted: #667085; + --kb-border: color-mix(in srgb, var(--kb-text) 14%, transparent); + --kb-accent: var(--wp--preset--color--accent-3, #2563eb); + --kb-accent-soft: color-mix(in srgb, var(--kb-accent) 10%, var(--kb-surface)); + color: var(--kb-text); +} + +.kb-docs-wrap { + max-width: 1320px; + margin: 0 auto; + padding: 24px 20px 48px; +} + +.kb-product-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; +} + +.kb-product-card, +.kb-search { + border: 1px solid var(--kb-border); + border-radius: 8px; + padding: 20px; + background: var(--kb-surface); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); +} + +.kb-product-card h2 { + margin-top: 0; + font-size: 20px; +} + +.kb-product-card ul, +.kb-version-list, +.kb-page-list { + margin-left: 0; + padding-left: 0; + list-style: none; +} + +.kb-product-card li, +.kb-version-list li, +.kb-page-list li { + margin: 7px 0; +} + +.kb-doc-layout { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + gap: 40px; + align-items: start; +} + +.kb-sidebar { + position: sticky; + top: 24px; + max-height: calc(100vh - 48px); + overflow: auto; + border-right: 1px solid var(--kb-border); + padding: 8px 22px 16px 0; +} + +.kb-sidebar select, +.kb-search input { + width: 100%; + min-height: 40px; + border: 1px solid var(--kb-border); + border-radius: 6px; + background: var(--kb-surface); + color: var(--kb-text); +} + +.kb-sidebar-nav ul { + list-style: none; + margin: 12px 0 0; + padding-left: 0; +} + +.kb-sidebar-nav ul ul { + padding-left: 16px; +} + +.kb-sidebar-nav a, +.kb-sidebar-nav span { + display: block; + padding: 6px 8px; + border-radius: 4px; + font-size: 14px; + line-height: 1.35; + text-decoration: none; + color: var(--kb-text); +} + +.kb-sidebar-nav a:hover { + background: var(--kb-accent-soft); + color: var(--kb-accent); +} + +.kb-breadcrumbs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 18px; + color: var(--kb-muted); + font-size: 14px; +} + +.kb-breadcrumbs a { + color: inherit; + text-decoration: none; +} + +.kb-breadcrumbs a:hover { + color: var(--kb-accent); +} + +.kb-rendered-content pre { + overflow: auto; + padding: 16px; + background: #1e1e1e; + color: #f8f8f2; + border-radius: 6px; +} + +.kb-doc-content { + max-width: 860px; + min-width: 0; +} + +.kb-doc-content > 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 .= "'; +}; +?> +
+ +
+ +

+
+ post_content))); ?> +
+
+
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); ?>

+ +
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 @@ +