initial COmmit: Add KB Antora Importer plugin files
This commit is contained in:
BIN
kb-antora-importer.zip
Normal file
BIN
kb-antora-importer.zip
Normal file
Binary file not shown.
210
kb-antora-importer/assets/css/frontend.css
Normal file
210
kb-antora-importer/assets/css/frontend.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
kb-antora-importer/assets/js/frontend.js
Normal file
13
kb-antora-importer/assets/js/frontend.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
14
kb-antora-importer/composer.json
Normal file
14
kb-antora-importer/composer.json
Normal file
@@ -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/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
kb-antora-importer/includes/Access/AccessController.php
Normal file
25
kb-antora-importer/includes/Access/AccessController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
171
kb-antora-importer/includes/Admin/SettingsPage.php
Normal file
171
kb-antora-importer/includes/Admin/SettingsPage.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
kb-antora-importer/includes/Admin/StatusPage.php
Normal file
78
kb-antora-importer/includes/Admin/StatusPage.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
98
kb-antora-importer/includes/Admin/SyncPage.php
Normal file
98
kb-antora-importer/includes/Admin/SyncPage.php
Normal 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']));
|
||||||
|
}
|
||||||
|
}
|
||||||
67
kb-antora-importer/includes/Antora/AntoraNavParser.php
Normal file
67
kb-antora-importer/includes/Antora/AntoraNavParser.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
kb-antora-importer/includes/Antora/AntoraParser.php
Normal file
22
kb-antora-importer/includes/Antora/AntoraParser.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
44
kb-antora-importer/includes/Antora/AntoraYamlReader.php
Normal file
44
kb-antora-importer/includes/Antora/AntoraYamlReader.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
kb-antora-importer/includes/AsciiDoc/AsciiDocRenderer.php
Normal file
132
kb-antora-importer/includes/AsciiDoc/AsciiDocRenderer.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
30
kb-antora-importer/includes/Frontend/BreadcrumbBuilder.php
Normal file
30
kb-antora-importer/includes/Frontend/BreadcrumbBuilder.php
Normal 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>';
|
||||||
|
}
|
||||||
|
}
|
||||||
385
kb-antora-importer/includes/Frontend/Router.php
Normal file
385
kb-antora-importer/includes/Frontend/Router.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
kb-antora-importer/includes/Frontend/SearchController.php
Normal file
69
kb-antora-importer/includes/Frontend/SearchController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
kb-antora-importer/includes/Frontend/TemplateLoader.php
Normal file
28
kb-antora-importer/includes/Frontend/TemplateLoader.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
163
kb-antora-importer/includes/Frontend/UrlBuilder.php
Normal file
163
kb-antora-importer/includes/Frontend/UrlBuilder.php
Normal 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)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
kb-antora-importer/includes/GitLab/GitLabBranch.php
Normal file
13
kb-antora-importer/includes/GitLab/GitLabBranch.php
Normal 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 = ''
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
194
kb-antora-importer/includes/GitLab/GitLabClient.php
Normal file
194
kb-antora-importer/includes/GitLab/GitLabClient.php
Normal 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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
kb-antora-importer/includes/GitLab/GitLabProject.php
Normal file
14
kb-antora-importer/includes/GitLab/GitLabProject.php
Normal 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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
12
kb-antora-importer/includes/Import/Checksum.php
Normal file
12
kb-antora-importer/includes/Import/Checksum.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
kb-antora-importer/includes/Import/ImportJob.php
Normal file
14
kb-antora-importer/includes/Import/ImportJob.php
Normal 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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
61
kb-antora-importer/includes/Import/ImportLogger.php
Normal file
61
kb-antora-importer/includes/Import/ImportLogger.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
382
kb-antora-importer/includes/Import/ImportManager.php
Normal file
382
kb-antora-importer/includes/Import/ImportManager.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
231
kb-antora-importer/includes/Plugin.php
Normal file
231
kb-antora-importer/includes/Plugin.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
kb-antora-importer/includes/Repository/PageRepository.php
Normal file
130
kb-antora-importer/includes/Repository/PageRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
kb-antora-importer/includes/Repository/ProductRepository.php
Normal file
19
kb-antora-importer/includes/Repository/ProductRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
kb-antora-importer/includes/Repository/VersionRepository.php
Normal file
18
kb-antora-importer/includes/Repository/VersionRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
kb-antora-importer/includes/Settings.php
Normal file
24
kb-antora-importer/includes/Settings.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
kb-antora-importer/kb-antora-importer.php
Normal file
42
kb-antora-importer/kb-antora-importer.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: KB Antora Importer
|
||||||
|
* Description: Imports GitLab/Antora based AsciiDoc documentation into WordPress as a versioned knowledgebase.
|
||||||
|
* Version: 0.1.0
|
||||||
|
* Requires PHP: 8.1
|
||||||
|
* Author: Codex
|
||||||
|
* Text Domain: kb-antora-importer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (! defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
define('KB_ANTORA_IMPORTER_VERSION', '0.1.0');
|
||||||
|
define('KB_ANTORA_IMPORTER_FILE', __FILE__);
|
||||||
|
define('KB_ANTORA_IMPORTER_DIR', plugin_dir_path(__FILE__));
|
||||||
|
define('KB_ANTORA_IMPORTER_URL', plugin_dir_url(__FILE__));
|
||||||
|
|
||||||
|
spl_autoload_register(static function (string $class): void {
|
||||||
|
$prefix = 'KbAntoraImporter\\';
|
||||||
|
|
||||||
|
if (0 !== strncmp($class, $prefix, strlen($prefix))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = substr($class, strlen($prefix));
|
||||||
|
$path = KB_ANTORA_IMPORTER_DIR . 'includes/' . str_replace('\\', '/', $relative) . '.php';
|
||||||
|
|
||||||
|
if (is_readable($path)) {
|
||||||
|
require_once $path;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
register_activation_hook(__FILE__, [KbAntoraImporter\Plugin::class, 'activate']);
|
||||||
|
register_deactivation_hook(__FILE__, [KbAntoraImporter\Plugin::class, 'deactivate']);
|
||||||
|
|
||||||
|
add_action('plugins_loaded', static function (): void {
|
||||||
|
KbAntoraImporter\Plugin::instance()->boot();
|
||||||
|
});
|
||||||
18
kb-antora-importer/readme.md
Normal file
18
kb-antora-importer/readme.md
Normal file
@@ -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.
|
||||||
22
kb-antora-importer/templates/documentation-index.php
Normal file
22
kb-antora-importer/templates/documentation-index.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
?>
|
||||||
|
<main class="kb-docs-wrap">
|
||||||
|
<h1><?php esc_html_e('Knowledgebase', 'kb-antora-importer'); ?></h1>
|
||||||
|
<?php echo do_shortcode('[kb_search]'); ?>
|
||||||
|
<div class="kb-product-list">
|
||||||
|
<?php foreach ((array) $products as $item) : ?>
|
||||||
|
<?php $term = $item['term']; ?>
|
||||||
|
<section class="kb-product-card">
|
||||||
|
<h2><a href="<?php echo esc_url($url_builder::product($term->slug)); ?>"><?php echo esc_html($term->name); ?></a></h2>
|
||||||
|
<?php if (! empty($item['versions'])) : ?>
|
||||||
|
<ul>
|
||||||
|
<?php foreach ($item['versions'] as $version) : ?>
|
||||||
|
<li><a href="<?php echo esc_url($url_builder::version($term->slug, $version->slug)); ?>"><?php echo esc_html($version->name); ?></a></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
56
kb-antora-importer/templates/page.php
Normal file
56
kb-antora-importer/templates/page.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
$render_nav = static function (array $nodes) use (&$render_nav, $base_slug, $product_slug, $version_slug): void {
|
||||||
|
if (! $nodes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '<ul>';
|
||||||
|
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 '<li>';
|
||||||
|
if ($href) {
|
||||||
|
printf('<a href="%s">%s</a>', esc_url($href), esc_html($label));
|
||||||
|
} else {
|
||||||
|
echo '<span>' . esc_html($label) . '</span>';
|
||||||
|
}
|
||||||
|
$render_nav((array) ($node['children'] ?? []));
|
||||||
|
echo '</li>';
|
||||||
|
}
|
||||||
|
echo '</ul>';
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<main class="kb-docs-wrap kb-doc-layout">
|
||||||
|
<aside class="kb-sidebar">
|
||||||
|
<label class="screen-reader-text" for="kb-version-switcher"><?php esc_html_e('Version', 'kb-antora-importer'); ?></label>
|
||||||
|
<select id="kb-version-switcher">
|
||||||
|
<?php foreach ((array) $versions as $item) : ?>
|
||||||
|
<option value="<?php echo esc_attr($item->slug); ?>" data-url="<?php echo esc_url($url_builder::version($product_slug, $item->slug)); ?>" <?php selected($item->slug, $version_slug); ?>><?php echo esc_html($item->name); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<nav class="kb-sidebar-nav" aria-label="<?php esc_attr_e('Documentation navigation', 'kb-antora-importer'); ?>">
|
||||||
|
<?php $render_nav((array) $nav_tree); ?>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<article class="kb-doc-content">
|
||||||
|
<nav class="kb-breadcrumbs">
|
||||||
|
<a href="<?php echo esc_url($url_builder::docsIndex()); ?>"><?php esc_html_e('Docs', 'kb-antora-importer'); ?></a><span>/</span>
|
||||||
|
<a href="<?php echo esc_url($url_builder::product($product_slug)); ?>"><?php echo esc_html($product ? $product->name : $product_slug); ?></a><span>/</span>
|
||||||
|
<a href="<?php echo esc_url($url_builder::version($product_slug, $version_slug)); ?>"><?php echo esc_html($version ? $version->name : $version_slug); ?></a>
|
||||||
|
</nav>
|
||||||
|
<h1><?php echo esc_html(get_the_title($post)); ?></h1>
|
||||||
|
<div class="kb-rendered-content">
|
||||||
|
<?php echo wp_kses_post($url_builder::rewriteHtml(apply_filters('the_content', $post->post_content))); ?>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
16
kb-antora-importer/templates/product.php
Normal file
16
kb-antora-importer/templates/product.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
?>
|
||||||
|
<main class="kb-docs-wrap">
|
||||||
|
<nav class="kb-breadcrumbs"><a href="<?php echo esc_url($url_builder::docsIndex()); ?>"><?php esc_html_e('Docs', 'kb-antora-importer'); ?></a><span>/</span><?php echo esc_html($product->name); ?></nav>
|
||||||
|
<h1><?php echo esc_html($product->name); ?></h1>
|
||||||
|
<h2><?php esc_html_e('Available Versions', 'kb-antora-importer'); ?></h2>
|
||||||
|
<ul class="kb-version-list">
|
||||||
|
<?php foreach ((array) $versions as $index => $version) : ?>
|
||||||
|
<li>
|
||||||
|
<a href="<?php echo esc_url($url_builder::version($product->slug, $version->slug)); ?>"><?php echo esc_html($version->name); ?></a>
|
||||||
|
<?php if (0 === $index) : ?><span class="kb-current-version"><?php esc_html_e('current', 'kb-antora-importer'); ?></span><?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
26
kb-antora-importer/templates/search.php
Normal file
26
kb-antora-importer/templates/search.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
?>
|
||||||
|
<section class="kb-search">
|
||||||
|
<h2><?php echo esc_html($title ?? __('Search Documentation', 'kb-antora-importer')); ?></h2>
|
||||||
|
<form method="get" class="kb-search-form">
|
||||||
|
<input type="search" name="kbq" value="<?php echo esc_attr($query ?? ''); ?>" placeholder="<?php esc_attr_e('Search documentation', 'kb-antora-importer'); ?>">
|
||||||
|
<button type="submit"><?php esc_html_e('Search', 'kb-antora-importer'); ?></button>
|
||||||
|
</form>
|
||||||
|
<?php if (! empty($results)) : ?>
|
||||||
|
<ul class="kb-search-results">
|
||||||
|
<?php foreach ((array) $results as $result) : ?>
|
||||||
|
<?php
|
||||||
|
$product = get_post_meta($result->ID, '_kb_product_slug', true);
|
||||||
|
$version = get_post_meta($result->ID, '_kb_version_slug', true);
|
||||||
|
$page = get_post_meta($result->ID, '_kb_page_slug', true);
|
||||||
|
$url = \KbAntoraImporter\Frontend\UrlBuilder::page((string) $product, (string) $version, (string) $page);
|
||||||
|
?>
|
||||||
|
<li>
|
||||||
|
<a href="<?php echo esc_url($url); ?>"><?php echo esc_html(get_the_title($result)); ?></a>
|
||||||
|
<p><?php echo esc_html(wp_trim_words(wp_strip_all_tags($result->post_content), 24)); ?></p>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
17
kb-antora-importer/templates/version.php
Normal file
17
kb-antora-importer/templates/version.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
?>
|
||||||
|
<main class="kb-docs-wrap">
|
||||||
|
<nav class="kb-breadcrumbs">
|
||||||
|
<a href="<?php echo esc_url($url_builder::docsIndex()); ?>"><?php esc_html_e('Docs', 'kb-antora-importer'); ?></a><span>/</span>
|
||||||
|
<a href="<?php echo esc_url($url_builder::product($product->slug)); ?>"><?php echo esc_html($product->name); ?></a><span>/</span>
|
||||||
|
<?php echo esc_html($version->name); ?>
|
||||||
|
</nav>
|
||||||
|
<h1><?php echo esc_html($product->name . ' ' . $version->name); ?></h1>
|
||||||
|
<ul class="kb-page-list">
|
||||||
|
<?php foreach ((array) $pages as $page) : ?>
|
||||||
|
<?php $slug = (string) get_post_meta($page->ID, '_kb_page_slug', true); ?>
|
||||||
|
<li><a href="<?php echo esc_url($url_builder::page($product->slug, $version->slug, $slug)); ?>"><?php echo esc_html(get_the_title($page)); ?></a></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
11
kb-antora-importer/uninstall.php
Normal file
11
kb-antora-importer/uninstall.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (! defined('WP_UNINSTALL_PLUGIN')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_option('kb_antora_importer_settings');
|
||||||
|
delete_option('kb_antora_importer_logs');
|
||||||
|
delete_option('kb_antora_importer_last_sync');
|
||||||
|
delete_option('kb_antora_importer_last_error');
|
||||||
Reference in New Issue
Block a user