initial COmmit: Add KB Antora Importer plugin files

This commit is contained in:
Sven Steinert
2026-05-12 14:37:09 +02:00
parent cf253c1367
commit 6abf6f9c3d
39 changed files with 2945 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Access;
use KbAntoraImporter\Plugin;
final class AccessController
{
public function canView(): bool
{
$settings = Plugin::settings();
return '1' === $settings['public_docs'] || is_user_logged_in() || current_user_can('view_kb_docs');
}
public function enforce(): void
{
if ($this->canView()) {
return;
}
auth_redirect();
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Admin;
use KbAntoraImporter\GitLab\GitLabClient;
use KbAntoraImporter\Import\ImportLogger;
use KbAntoraImporter\Plugin;
use KbAntoraImporter\Settings;
final class SettingsPage
{
public static function registerSettings(): void
{
register_setting('kb_antora_importer_settings', 'kb_antora_importer_settings', [
'type' => '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();
?>
<div class="wrap">
<h1><?php esc_html_e('Knowledgebase Settings', 'kb-antora-importer'); ?></h1>
<form method="post" action="options.php">
<?php settings_fields('kb_antora_importer_settings'); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="gitlab_base_url">GitLab Base URL</label></th>
<td>
<input class="regular-text" id="gitlab_base_url" name="kb_antora_importer_settings[gitlab_base_url]" type="url" value="<?php echo esc_attr($settings['gitlab_base_url']); ?>" placeholder="https://git.example.de">
<p class="description"><?php esc_html_e('Use the GitLab root URL, for example https://git.example.de. Do not include /api/v4.', 'kb-antora-importer'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="gitlab_token">GitLab API Token</label></th>
<td><input class="regular-text" id="gitlab_token" name="kb_antora_importer_settings[gitlab_token]" type="password" value="" placeholder="<?php echo $settings['gitlab_token'] ? esc_attr__('Token is stored; leave blank to keep it', 'kb-antora-importer') : ''; ?>"></td>
</tr>
<tr>
<th scope="row"><label for="gitlab_group">GitLab Group Path / ID</label></th>
<td><input class="regular-text" id="gitlab_group" name="kb_antora_importer_settings[gitlab_group]" type="text" value="<?php echo esc_attr($settings['gitlab_group']); ?>"></td>
</tr>
<tr>
<th scope="row"><label for="branch_pattern">Branch Pattern</label></th>
<td><input class="regular-text" id="branch_pattern" name="kb_antora_importer_settings[branch_pattern]" type="text" value="<?php echo esc_attr($settings['branch_pattern']); ?>"></td>
</tr>
<tr>
<th scope="row"><label for="docs_base_slug">Frontend Base Slug</label></th>
<td><input class="regular-text" id="docs_base_slug" name="kb_antora_importer_settings[docs_base_slug]" type="text" value="<?php echo esc_attr($settings['docs_base_slug']); ?>"></td>
</tr>
<tr>
<th scope="row"><label for="renderer_mode">Renderer Mode</label></th>
<td>
<select id="renderer_mode" name="kb_antora_importer_settings[renderer_mode]">
<option value="php" <?php selected($settings['renderer_mode'], 'php'); ?>>PHP Renderer</option>
<option value="asciidoctor" <?php selected($settings['renderer_mode'], 'asciidoctor'); ?>>Asciidoctor CLI</option>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="asciidoctor_path">Asciidoctor Path</label></th>
<td><input class="regular-text" id="asciidoctor_path" name="kb_antora_importer_settings[asciidoctor_path]" type="text" value="<?php echo esc_attr($settings['asciidoctor_path']); ?>"></td>
</tr>
<tr>
<th scope="row">Options</th>
<td>
<label><input type="checkbox" name="kb_antora_importer_settings[image_lightbox]" value="1" <?php checked($settings['image_lightbox'], '1'); ?>> Link images with lightbox class</label><br>
<label><input type="checkbox" name="kb_antora_importer_settings[public_docs]" value="1" <?php checked($settings['public_docs'], '1'); ?>> Show documentation publicly</label><br>
<label><input type="checkbox" name="kb_antora_importer_settings[allow_svg]" value="1" <?php checked($settings['allow_svg'], '1'); ?>> Allow SVG image import</label>
</td>
</tr>
<tr>
<th scope="row"><label for="cron_interval">Automatic Sync</label></th>
<td>
<select id="cron_interval" name="kb_antora_importer_settings[cron_interval]">
<option value="disabled" <?php selected($settings['cron_interval'], 'disabled'); ?>>Disabled</option>
<option value="hourly" <?php selected($settings['cron_interval'], 'hourly'); ?>>Hourly</option>
<option value="daily" <?php selected($settings['cron_interval'], 'daily'); ?>>Daily</option>
<option value="weekly" <?php selected($settings['cron_interval'], 'weekly'); ?>>Weekly</option>
</select>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
<form method="post">
<?php wp_nonce_field('kb_antora_test_connection'); ?>
<?php submit_button(__('Test GitLab Connection', 'kb-antora-importer'), 'secondary', 'kb_antora_test_connection'); ?>
</form>
</div>
<?php
}
private static function handleConnectionTest(): void
{
$client = new GitLabClient(Plugin::settings());
$result = $client->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;
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Admin;
use KbAntoraImporter\Import\ImportLogger;
use KbAntoraImporter\Plugin;
final class StatusPage
{
public static function render(): void
{
if (! current_user_can('manage_kb_docs')) {
wp_die(esc_html__('Insufficient permissions.', 'kb-antora-importer'));
}
$settings = Plugin::settings();
$counts = self::counts();
$logs = ImportLogger::recent(20);
?>
<div class="wrap">
<h1><?php esc_html_e('Knowledgebase Overview', 'kb-antora-importer'); ?></h1>
<div class="kb-admin-grid">
<div class="kb-admin-card"><strong>GitLab</strong><span><?php echo esc_html($settings['gitlab_base_url'] ?: __('Not configured', 'kb-antora-importer')); ?></span></div>
<div class="kb-admin-card"><strong>Products</strong><span><?php echo esc_html((string) $counts['products']); ?></span></div>
<div class="kb-admin-card"><strong>Versions</strong><span><?php echo esc_html((string) $counts['versions']); ?></span></div>
<div class="kb-admin-card"><strong>Pages</strong><span><?php echo esc_html((string) $counts['pages']); ?></span></div>
<div class="kb-admin-card"><strong>Last sync</strong><span><?php echo esc_html((string) get_option('kb_antora_importer_last_sync', __('Never', 'kb-antora-importer'))); ?></span></div>
<div class="kb-admin-card"><strong>Renderer</strong><span><?php echo esc_html($settings['renderer_mode']); ?></span></div>
</div>
<h2><?php esc_html_e('Recent Import Logs', 'kb-antora-importer'); ?></h2>
<?php self::renderLogTable($logs); ?>
</div>
<?php
}
public static function restStatus(): \WP_REST_Response
{
return new \WP_REST_Response([
'settings_complete' => (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 '<p>' . esc_html__('No logs yet.', 'kb-antora-importer') . '</p>';
return;
}
echo '<table class="widefat striped"><thead><tr><th>Time</th><th>Level</th><th>Message</th></tr></thead><tbody>';
foreach ($logs as $entry) {
printf(
'<tr><td>%s</td><td><strong>%s</strong></td><td>%s</td></tr>',
esc_html((string) ($entry['time'] ?? '')),
esc_html((string) ($entry['level'] ?? 'INFO')),
esc_html((string) ($entry['message'] ?? ''))
);
}
echo '</tbody></table>';
}
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),
];
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Admin;
use KbAntoraImporter\GitLab\GitLabClient;
use KbAntoraImporter\Import\ImportLogger;
use KbAntoraImporter\Import\ImportManager;
use KbAntoraImporter\Plugin;
final class SyncPage
{
public static function render(): void
{
if (! current_user_can('sync_kb_docs')) {
wp_die(esc_html__('Insufficient permissions.', 'kb-antora-importer'));
}
self::handleActions();
$projects = self::loadProjects();
?>
<div class="wrap">
<h1><?php esc_html_e('Knowledgebase Synchronization', 'kb-antora-importer'); ?></h1>
<form method="post" class="kb-sync-actions">
<?php wp_nonce_field('kb_antora_sync'); ?>
<?php submit_button(__('Sync All', 'kb-antora-importer'), 'primary', 'kb_antora_sync_all', false); ?>
<?php submit_button(__('Dry Run', 'kb-antora-importer'), 'secondary', 'kb_antora_dry_run', false); ?>
</form>
<h2><?php esc_html_e('Projects', 'kb-antora-importer'); ?></h2>
<?php if (is_wp_error($projects)) : ?>
<div class="notice notice-error"><p><?php echo esc_html($projects->get_error_message()); ?></p></div>
<?php elseif (! $projects) : ?>
<p><?php esc_html_e('No projects loaded. Check GitLab settings first.', 'kb-antora-importer'); ?></p>
<?php else : ?>
<table class="widefat striped">
<thead><tr><th>Name</th><th>Path</th><th>Action</th></tr></thead>
<tbody>
<?php foreach ($projects as $project) : ?>
<tr>
<td><?php echo esc_html((string) ($project['name'] ?? '')); ?></td>
<td><code><?php echo esc_html((string) ($project['path_with_namespace'] ?? $project['path'] ?? '')); ?></code></td>
<td>
<form method="post">
<?php wp_nonce_field('kb_antora_sync_project'); ?>
<input type="hidden" name="project_id" value="<?php echo esc_attr((string) ($project['id'] ?? '')); ?>">
<?php submit_button(__('Sync Project', 'kb-antora-importer'), 'secondary small', 'kb_antora_sync_project', false); ?>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h2><?php esc_html_e('Import Logs', 'kb-antora-importer'); ?></h2>
<?php StatusPage::renderLogTable(ImportLogger::recent(100)); ?>
</div>
<?php
}
private static function handleActions(): void
{
if (isset($_POST['kb_antora_sync_all']) && check_admin_referer('kb_antora_sync')) {
(new ImportManager())->syncAll(false);
echo '<div class="notice notice-success"><p>' . esc_html__('Synchronization finished.', 'kb-antora-importer') . '</p></div>';
}
if (isset($_POST['kb_antora_dry_run']) && check_admin_referer('kb_antora_sync')) {
(new ImportManager())->syncAll(true);
echo '<div class="notice notice-info"><p>' . esc_html__('Dry run finished.', 'kb-antora-importer') . '</p></div>';
}
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 '<div class="notice notice-success"><p>' . esc_html__('Project synchronization finished.', 'kb-antora-importer') . '</p></div>';
}
}
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']));
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Antora;
final class AntoraNavParser
{
public function parse(string $nav): array
{
$root = [];
$stack = [];
foreach (preg_split('/\R/', $nav) ?: [] as $line) {
if (! preg_match('/^(\*+)\s+(.+)$/', trim($line), $matches)) {
continue;
}
$level = strlen($matches[1]);
$raw = trim($matches[2]);
$item = [
'title' => $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;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Antora;
final class AntoraParser
{
public function pageSlugFromPath(string $path): string
{
$name = preg_replace('/\.adoc$/', '', basename($path)) ?: basename($path);
return 'index' === $name ? '' : sanitize_title($name);
}
public function moduleFromPath(string $path): string
{
if (preg_match('#^modules/([^/]+)/#', $path, $matches)) {
return $matches[1];
}
return 'ROOT';
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Antora;
final class AntoraResourceResolver
{
public function imagePath(string $imageName, string $module = 'ROOT'): string
{
return 'modules/' . trim($module, '/') . '/images/' . ltrim($imageName, '/');
}
public function partialPath(string $partialName, string $module = 'ROOT'): string
{
return 'modules/' . trim($module, '/') . '/partials/' . ltrim($partialName, '/');
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Antora;
final class AntoraYamlReader
{
public function parse(string $yaml): array
{
$data = [
'name' => '',
'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;
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\AsciiDoc;
final class AsciiDocRenderer
{
private ShortcodeTransformer $transformer;
public function __construct(?ShortcodeTransformer $transformer = null)
{
$this->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 .= '<p>' . $this->transformer->transformInline($text, $context) . '</p>' . "\n";
$paragraph = [];
};
$closeList = static function () use (&$html, &$listOpen): void {
if ($listOpen) {
$html .= "</ul>\n";
$listOpen = false;
}
};
foreach ($lines as $line) {
$trimmed = trim($line);
if ('----' === $trimmed) {
$flushParagraph();
$closeList();
if ($codeOpen) {
$html .= '<pre><code>' . esc_html(implode("\n", $code)) . '</code></pre>' . "\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('<h%d>%s</h%d>', $level, esc_html($matches[2]), $level) . "\n";
continue;
}
if (preg_match('/^\*\s+(.+)$/', $trimmed, $matches)) {
$flushParagraph();
if (! $listOpen) {
$html .= "<ul>\n";
$listOpen = true;
}
$html .= '<li>' . $this->transformer->transformInline($matches[1], $context) . '</li>' . "\n";
continue;
}
if (preg_match('/^(NOTE|TIP|IMPORTANT|WARNING|CAUTION):\s+(.+)$/', $trimmed, $matches)) {
$flushParagraph();
$closeList();
$class = strtolower($matches[1]);
$html .= '<aside class="kb-admonition kb-admonition-' . esc_attr($class) . '"><strong>' . esc_html($matches[1]) . '</strong><p>' . $this->transformer->transformInline($matches[2], $context) . '</p></aside>' . "\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('<img src="%s" alt="%s">', esc_url($url), esc_attr($alt));
if (! empty($context['lightbox'])) {
$image = sprintf('<a href="%s" class="kb-lightbox">%s</a>', esc_url($url), $image);
}
$html .= '<figure class="kb-image">' . $image . '</figure>' . "\n";
} else {
$html .= '<figure class="kb-image kb-image-missing"><span>' . esc_html($alt) . '</span></figure>' . "\n";
}
continue;
}
if (preg_match('/^\|===/', $trimmed)) {
$flushParagraph();
$closeList();
$html .= "<hr>\n";
continue;
}
$paragraph[] = $line;
}
$flushParagraph();
$closeList();
return wp_kses_post($html);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\AsciiDoc;
use KbAntoraImporter\Frontend\UrlBuilder;
final class ShortcodeTransformer
{
public function transformInline(string $text, array $context): string
{
$pattern = '/\b(xref|link):([^\[]+)\[([^\]]*)\]/';
$output = '';
$offset = 0;
if (! preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
return esc_html($text);
}
foreach ($matches as $match) {
$start = (int) $match[0][1];
$output .= esc_html(substr($text, $offset, $start - $offset));
$output .= $this->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('<a href="%s" rel="nofollow noopener">%s</a>', esc_url($target), esc_html($label));
}
if (preg_match('#^https?://#i', $target)) {
return sprintf('<a href="%s" rel="nofollow noopener">%s</a>', 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('<a href="%s">%s</a>', esc_url($url), esc_html($label));
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Frontend;
use KbAntoraImporter\Plugin;
final class BreadcrumbBuilder
{
public function build(array $parts): string
{
$base = trim((string) Plugin::settings()['docs_base_slug'], '/');
$items = [
sprintf('<a href="%s">%s</a>', 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('<a href="%s">%s</a>', esc_url(home_url('/' . $path . '/')), esc_html((string) $label));
}
return '<nav class="kb-breadcrumbs" aria-label="Breadcrumb">' . implode('<span>/</span>', $items) . '</nav>';
}
}

View File

@@ -0,0 +1,385 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Frontend;
use KbAntoraImporter\Access\AccessController;
use KbAntoraImporter\Plugin;
use KbAntoraImporter\Repository\PageRepository;
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_antora_route=index', 'top');
add_rewrite_rule('^' . preg_quote($base, '#') . '/([^/]+)/?$', 'index.php?kb_antora_route=product&kb_product_slug=$matches[1]', 'top');
add_rewrite_rule('^' . preg_quote($base, '#') . '/([^/]+)/([^/]+)/?$', 'index.php?kb_antora_route=version&kb_product_slug=$matches[1]&kb_version_slug=$matches[2]', 'top');
add_rewrite_rule('^' . preg_quote($base, '#') . '/([^/]+)/([^/]+)/(.+?)/?$', 'index.php?kb_antora_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_antora_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_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;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Frontend;
use KbAntoraImporter\Access\AccessController;
final class SearchController
{
public static function shortcodeSearch(array $atts = []): string
{
if (! (new AccessController())->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;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Frontend;
final class TemplateLoader
{
public function render(string $template, array $vars = []): void
{
$path = KB_ANTORA_IMPORTER_DIR . 'templates/' . $template . '.php';
if (! is_readable($path)) {
status_header(500);
echo esc_html__('Knowledgebase template missing.', 'kb-antora-importer');
return;
}
extract($vars, EXTR_SKIP);
include $path;
}
public function capture(string $template, array $vars = []): string
{
ob_start();
$this->render($template, $vars);
return (string) ob_get_clean();
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Frontend;
use KbAntoraImporter\Plugin;
final class UrlBuilder
{
private static string $embedBaseUrl = '';
public static function beginEmbed(string $baseUrl): void
{
self::$embedBaseUrl = remove_query_arg(['kb_docs_route', 'kb_docs_product', 'kb_docs_version', 'kb_docs_page'], $baseUrl);
}
public static function endEmbed(): void
{
self::$embedBaseUrl = '';
}
public static function isEmbed(): bool
{
return '' !== self::$embedBaseUrl;
}
public static function docsIndex(): string
{
return self::route('index');
}
public static function product(string $productSlug): string
{
return self::route('product', $productSlug);
}
public static function version(string $productSlug, string $versionSlug): string
{
return self::route('version', $productSlug, $versionSlug);
}
public static function page(string $productSlug, string $versionSlug, string $pageSlug = ''): string
{
return self::route('page', $productSlug, $versionSlug, $pageSlug);
}
private static function route(string $route, string $productSlug = '', string $versionSlug = '', string $pageSlug = ''): string
{
if (self::isEmbed()) {
$args = ['kb_docs_route' => $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)))
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\GitLab;
final class GitLabBranch
{
public function __construct(
public readonly string $name,
public readonly string $commitSha = ''
) {
}
}

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\GitLab;
final class GitLabClient
{
private string $baseUrl;
private string $token;
private string $branchPattern;
public function __construct(array $settings)
{
$this->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,
]
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\GitLab;
final class GitLabProject
{
public function __construct(
public readonly string $id,
public readonly string $name,
public readonly string $pathWithNamespace
) {
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Import;
final class Checksum
{
public static function content(string $content): string
{
return hash('sha256', $content);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Import;
final class ImportJob
{
public function __construct(
public readonly string $projectId,
public readonly string $branch,
public readonly bool $dryRun = false
) {
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Import;
final class ImportLogger
{
private const OPTION = 'kb_antora_importer_logs';
private const LIMIT = 300;
public static function info(string $message): void
{
self::log('INFO', $message);
}
public static function warning(string $message): void
{
self::log('WARNING', $message);
}
public static function error(string $message): void
{
update_option('kb_antora_importer_last_error', self::sanitize($message), false);
self::log('ERROR', $message);
}
public static function debug(string $message): void
{
self::log('DEBUG', $message);
}
public static function recent(int $limit = 50): array
{
$logs = array_reverse((array) get_option(self::OPTION, []));
return array_slice($logs, 0, $limit);
}
private static function log(string $level, string $message): void
{
$logs = (array) get_option(self::OPTION, []);
$logs[] = [
'time' => 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);
}
}

View File

@@ -0,0 +1,382 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Import;
use KbAntoraImporter\Antora\AntoraNavParser;
use KbAntoraImporter\Antora\AntoraParser;
use KbAntoraImporter\Antora\AntoraYamlReader;
use KbAntoraImporter\AsciiDoc\AsciiDocRenderer;
use KbAntoraImporter\GitLab\GitLabClient;
use KbAntoraImporter\Plugin;
use KbAntoraImporter\Repository\PageRepository;
use KbAntoraImporter\Repository\ProductRepository;
use KbAntoraImporter\Repository\VersionRepository;
final class ImportManager
{
private GitLabClient $client;
private array $settings;
private AntoraParser $antora;
private AntoraYamlReader $yamlReader;
private AntoraNavParser $navParser;
private PageRepository $pages;
private ProductRepository $products;
private VersionRepository $versions;
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter;
use KbAntoraImporter\Admin\SettingsPage;
use KbAntoraImporter\Admin\StatusPage;
use KbAntoraImporter\Admin\SyncPage;
use KbAntoraImporter\Frontend\Router;
use KbAntoraImporter\Frontend\SearchController;
use KbAntoraImporter\Import\ImportManager;
final class Plugin
{
private static ?self $instance = null;
public static function instance(): self
{
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function boot(): void
{
add_action('init', [$this, 'registerContentTypes']);
add_action('init', [$this, 'registerShortcodes']);
add_action('admin_menu', [$this, 'registerAdminPages']);
add_action('admin_init', [SettingsPage::class, 'registerSettings']);
add_action('rest_api_init', [$this, 'registerRestRoutes']);
add_filter('cron_schedules', [$this, 'addCronSchedules']);
add_action('kb_antora_importer_cron_sync', [$this, 'runCronSync']);
add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendAssets']);
(new Router())->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);
}
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Repository;
use KbAntoraImporter\Import\ImportLogger;
final class PageRepository
{
public function findBySource(string $projectId, string $branch, string $sourcePath): int
{
$query = new \WP_Query([
'post_type' => '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;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Repository;
final class ProductRepository
{
public function ensure(string $name, string $slug = ''): int
{
$slug = $slug ? sanitize_title($slug) : sanitize_title($name);
$term = term_exists($slug, 'kb_product');
if (! $term) {
$term = wp_insert_term($name, 'kb_product', ['slug' => $slug]);
}
return is_wp_error($term) ? 0 : (int) ($term['term_id'] ?? $term);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter\Repository;
final class VersionRepository
{
public function ensure(string $version): int
{
$term = term_exists($version, 'kb_version');
if (! $term) {
$term = wp_insert_term($version, 'kb_version', ['slug' => sanitize_title($version)]);
}
return is_wp_error($term) ? 0 : (int) ($term['term_id'] ?? $term);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace KbAntoraImporter;
final class Settings
{
public static function defaults(): array
{
return [
'gitlab_base_url' => '',
'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',
];
}
}