MD Umbau
This commit is contained in:
25
kb-markdown-importer/includes/Access/AccessController.php
Normal file
25
kb-markdown-importer/includes/Access/AccessController.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Access;
|
||||
|
||||
use KbMarkdownImporter\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();
|
||||
}
|
||||
}
|
||||
209
kb-markdown-importer/includes/Admin/SettingsPage.php
Normal file
209
kb-markdown-importer/includes/Admin/SettingsPage.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Admin;
|
||||
|
||||
use KbMarkdownImporter\GitLab\GitLabClient;
|
||||
use KbMarkdownImporter\Import\ImportLogger;
|
||||
use KbMarkdownImporter\Plugin;
|
||||
use KbMarkdownImporter\Settings;
|
||||
|
||||
final class SettingsPage
|
||||
{
|
||||
public static function registerSettings(): void
|
||||
{
|
||||
register_setting('kb_markdown_importer_settings', 'kb_markdown_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['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';
|
||||
$settings['design_theme'] = in_array(($input['design_theme'] ?? 'obyte'), ['obyte', 'inherit'], true) ? (string) $input['design_theme'] : 'obyte';
|
||||
$settings['design_primary_color'] = self::sanitizeHexColor((string) ($input['design_primary_color'] ?? '#00A7E6'), '#00A7E6');
|
||||
$settings['design_accent_color'] = self::sanitizeHexColor((string) ($input['design_accent_color'] ?? '#F59C00'), '#F59C00');
|
||||
$settings['design_radius'] = (string) max(0, min(32, (int) ($input['design_radius'] ?? 14)));
|
||||
$settings['custom_theme_css_url'] = esc_url_raw((string) ($input['custom_theme_css_url'] ?? ''));
|
||||
|
||||
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-markdown-importer'));
|
||||
}
|
||||
|
||||
if (isset($_POST['kb_markdown_test_connection']) && check_admin_referer('kb_markdown_test_connection')) {
|
||||
self::handleConnectionTest();
|
||||
}
|
||||
|
||||
$settings = Plugin::settings();
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Markdown Knowledgebase Settings', 'kb-markdown-importer'); ?></h1>
|
||||
<form method="post" action="options.php">
|
||||
<?php settings_fields('kb_markdown_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_markdown_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-markdown-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_markdown_importer_settings[gitlab_token]" type="password" value="" placeholder="<?php echo $settings['gitlab_token'] ? esc_attr__('Token is stored; leave blank to keep it', 'kb-markdown-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_markdown_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_markdown_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_markdown_importer_settings[docs_base_slug]" type="text" value="<?php echo esc_attr($settings['docs_base_slug']); ?>"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Markdown Project Structure</th>
|
||||
<td>
|
||||
<p class="description"><?php esc_html_e('Each version branch must contain doku.md, stepbystep.md and an images/ folder. Optional files such as faq.md and doku.yml are supported.', 'kb-markdown-importer'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Options</th>
|
||||
<td>
|
||||
<label><input type="checkbox" name="kb_markdown_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_markdown_importer_settings[public_docs]" value="1" <?php checked($settings['public_docs'], '1'); ?>> Show documentation publicly</label><br>
|
||||
<label><input type="checkbox" name="kb_markdown_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_markdown_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>
|
||||
<h2><?php esc_html_e('Frontend Design', 'kb-markdown-importer'); ?></h2>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th scope="row"><label for="design_theme">Design Theme</label></th>
|
||||
<td>
|
||||
<select id="design_theme" name="kb_markdown_importer_settings[design_theme]">
|
||||
<option value="obyte" <?php selected($settings['design_theme'], 'obyte'); ?>>o-byte</option>
|
||||
<option value="inherit" <?php selected($settings['design_theme'], 'inherit'); ?>>Theme inherit / neutral</option>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e('The bundled o-byte theme is loaded by default. A custom theme.css is loaded after it and can override everything.', 'kb-markdown-importer'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="design_primary_color">Primary Color</label></th>
|
||||
<td><input id="design_primary_color" name="kb_markdown_importer_settings[design_primary_color]" type="color" value="<?php echo esc_attr($settings['design_primary_color']); ?>"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="design_accent_color">Accent Color</label></th>
|
||||
<td><input id="design_accent_color" name="kb_markdown_importer_settings[design_accent_color]" type="color" value="<?php echo esc_attr($settings['design_accent_color']); ?>"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="design_radius">Border Radius</label></th>
|
||||
<td>
|
||||
<input id="design_radius" name="kb_markdown_importer_settings[design_radius]" type="number" min="0" max="32" value="<?php echo esc_attr($settings['design_radius']); ?>"> px
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="custom_theme_css_url">Custom theme.css</label></th>
|
||||
<td>
|
||||
<input class="regular-text" id="custom_theme_css_url" name="kb_markdown_importer_settings[custom_theme_css_url]" type="url" value="<?php echo esc_attr($settings['custom_theme_css_url']); ?>" placeholder="https://example.com/theme.css">
|
||||
<button type="button" class="button" id="kb-markdown-upload-theme-css"><?php esc_html_e('Upload/select theme.css', 'kb-markdown-importer'); ?></button>
|
||||
<p class="description"><?php esc_html_e('Upload a CSS file in the media library or paste a CSS URL. It is enqueued last, so it can override the bundled theme.', 'kb-markdown-importer'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php submit_button(); ?>
|
||||
</form>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('kb_markdown_test_connection'); ?>
|
||||
<?php submit_button(__('Test GitLab Connection', 'kb-markdown-importer'), 'secondary', 'kb_markdown_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_markdown_importer', 'connection_failed', esc_html($message), 'error');
|
||||
settings_errors('kb_markdown_importer');
|
||||
return;
|
||||
}
|
||||
|
||||
ImportLogger::info('GitLab connection successful.');
|
||||
add_settings_error('kb_markdown_importer', 'connection_ok', esc_html__('GitLab connection successful.', 'kb-markdown-importer'), 'success');
|
||||
settings_errors('kb_markdown_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;
|
||||
}
|
||||
|
||||
private static function sanitizeHexColor(string $value, string $fallback): string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
return preg_match('/^#[0-9a-fA-F]{6}$/', $value) ? strtoupper($value) : $fallback;
|
||||
}
|
||||
}
|
||||
78
kb-markdown-importer/includes/Admin/StatusPage.php
Normal file
78
kb-markdown-importer/includes/Admin/StatusPage.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Admin;
|
||||
|
||||
use KbMarkdownImporter\Import\ImportLogger;
|
||||
use KbMarkdownImporter\Plugin;
|
||||
|
||||
final class StatusPage
|
||||
{
|
||||
public static function render(): void
|
||||
{
|
||||
if (! current_user_can('manage_kb_docs')) {
|
||||
wp_die(esc_html__('Insufficient permissions.', 'kb-markdown-importer'));
|
||||
}
|
||||
|
||||
$settings = Plugin::settings();
|
||||
$counts = self::counts();
|
||||
$logs = ImportLogger::recent(20);
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Knowledgebase Overview', 'kb-markdown-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-markdown-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_markdown_importer_last_sync', __('Never', 'kb-markdown-importer'))); ?></span></div>
|
||||
<div class="kb-admin-card"><strong>Format</strong><span>Markdown</span></div>
|
||||
</div>
|
||||
<h2><?php esc_html_e('Recent Import Logs', 'kb-markdown-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_markdown_importer_last_sync', ''),
|
||||
'last_error' => get_option('kb_markdown_importer_last_error', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function renderLogTable(array $logs): void
|
||||
{
|
||||
if (! $logs) {
|
||||
echo '<p>' . esc_html__('No logs yet.', 'kb-markdown-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-markdown-importer/includes/Admin/SyncPage.php
Normal file
98
kb-markdown-importer/includes/Admin/SyncPage.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Admin;
|
||||
|
||||
use KbMarkdownImporter\GitLab\GitLabClient;
|
||||
use KbMarkdownImporter\Import\ImportLogger;
|
||||
use KbMarkdownImporter\Import\ImportManager;
|
||||
use KbMarkdownImporter\Plugin;
|
||||
|
||||
final class SyncPage
|
||||
{
|
||||
public static function render(): void
|
||||
{
|
||||
if (! current_user_can('sync_kb_docs')) {
|
||||
wp_die(esc_html__('Insufficient permissions.', 'kb-markdown-importer'));
|
||||
}
|
||||
|
||||
self::handleActions();
|
||||
$projects = self::loadProjects();
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Knowledgebase Synchronization', 'kb-markdown-importer'); ?></h1>
|
||||
<form method="post" class="kb-sync-actions">
|
||||
<?php wp_nonce_field('kb_markdown_sync'); ?>
|
||||
<?php submit_button(__('Sync All', 'kb-markdown-importer'), 'primary', 'kb_markdown_sync_all', false); ?>
|
||||
<?php submit_button(__('Dry Run', 'kb-markdown-importer'), 'secondary', 'kb_markdown_dry_run', false); ?>
|
||||
</form>
|
||||
|
||||
<h2><?php esc_html_e('Projects', 'kb-markdown-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-markdown-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_markdown_sync_project'); ?>
|
||||
<input type="hidden" name="project_id" value="<?php echo esc_attr((string) ($project['id'] ?? '')); ?>">
|
||||
<?php submit_button(__('Sync Project', 'kb-markdown-importer'), 'secondary small', 'kb_markdown_sync_project', false); ?>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e('Import Logs', 'kb-markdown-importer'); ?></h2>
|
||||
<?php StatusPage::renderLogTable(ImportLogger::recent(100)); ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
private static function handleActions(): void
|
||||
{
|
||||
if (isset($_POST['kb_markdown_sync_all']) && check_admin_referer('kb_markdown_sync')) {
|
||||
(new ImportManager())->syncAll(false);
|
||||
echo '<div class="notice notice-success"><p>' . esc_html__('Synchronization finished.', 'kb-markdown-importer') . '</p></div>';
|
||||
}
|
||||
|
||||
if (isset($_POST['kb_markdown_dry_run']) && check_admin_referer('kb_markdown_sync')) {
|
||||
(new ImportManager())->syncAll(true);
|
||||
echo '<div class="notice notice-info"><p>' . esc_html__('Dry run finished.', 'kb-markdown-importer') . '</p></div>';
|
||||
}
|
||||
|
||||
if (isset($_POST['kb_markdown_sync_project']) && check_admin_referer('kb_markdown_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-markdown-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']));
|
||||
}
|
||||
}
|
||||
30
kb-markdown-importer/includes/Frontend/BreadcrumbBuilder.php
Normal file
30
kb-markdown-importer/includes/Frontend/BreadcrumbBuilder.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Frontend;
|
||||
|
||||
use KbMarkdownImporter\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-markdown-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>';
|
||||
}
|
||||
}
|
||||
413
kb-markdown-importer/includes/Frontend/Router.php
Normal file
413
kb-markdown-importer/includes/Frontend/Router.php
Normal file
@@ -0,0 +1,413 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Frontend;
|
||||
|
||||
use KbMarkdownImporter\Access\AccessController;
|
||||
use KbMarkdownImporter\Plugin;
|
||||
use KbMarkdownImporter\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_markdown_route=index', 'top');
|
||||
add_rewrite_rule('^' . preg_quote($base, '#') . '/([^/]+)/?$', 'index.php?kb_markdown_route=product&kb_product_slug=$matches[1]', 'top');
|
||||
add_rewrite_rule('^' . preg_quote($base, '#') . '/([^/]+)/([^/]+)/?$', 'index.php?kb_markdown_route=version&kb_product_slug=$matches[1]&kb_version_slug=$matches[2]', 'top');
|
||||
add_rewrite_rule('^' . preg_quote($base, '#') . '/([^/]+)/([^/]+)/(.+?)/?$', 'index.php?kb_markdown_route=page&kb_product_slug=$matches[1]&kb_version_slug=$matches[2]&kb_page_slug=$matches[3]', 'top');
|
||||
}
|
||||
|
||||
public function addQueryVars(array $vars): array
|
||||
{
|
||||
return array_merge($vars, ['kb_markdown_route', 'kb_product_slug', 'kb_version_slug', 'kb_page_slug']);
|
||||
}
|
||||
|
||||
public function routeRequest(array $queryVars): array
|
||||
{
|
||||
$route = $this->routeFromRequestUri();
|
||||
|
||||
if (! $route) {
|
||||
return $queryVars;
|
||||
}
|
||||
|
||||
$queryVars = [
|
||||
'kb_markdown_route' => $route['route'],
|
||||
];
|
||||
|
||||
if (! empty($route['product'])) {
|
||||
$queryVars['kb_product_slug'] = $route['product'];
|
||||
}
|
||||
|
||||
if (! empty($route['version'])) {
|
||||
$queryVars['kb_version_slug'] = $route['version'];
|
||||
}
|
||||
|
||||
if (! empty($route['page'])) {
|
||||
$queryVars['kb_page_slug'] = $route['page'];
|
||||
}
|
||||
|
||||
return $queryVars;
|
||||
}
|
||||
|
||||
public function dispatch(): void
|
||||
{
|
||||
$route = get_query_var('kb_markdown_route');
|
||||
$requestRoute = $this->routeFromRequestUri();
|
||||
|
||||
if (! $route && $requestRoute) {
|
||||
$route = $requestRoute['route'];
|
||||
}
|
||||
|
||||
if (! $route) {
|
||||
$queryRoute = $this->routeFromQuery();
|
||||
if (! $queryRoute) {
|
||||
return;
|
||||
}
|
||||
|
||||
$requestRoute = $queryRoute;
|
||||
$route = $queryRoute['route'];
|
||||
}
|
||||
|
||||
(new AccessController())->enforce();
|
||||
|
||||
get_header();
|
||||
|
||||
match ($route) {
|
||||
'index' => $this->renderIndex(),
|
||||
'product' => $this->renderProduct((string) ($requestRoute['product'] ?? get_query_var('kb_product_slug'))),
|
||||
'version' => $this->renderVersion((string) ($requestRoute['product'] ?? get_query_var('kb_product_slug')), (string) ($requestRoute['version'] ?? get_query_var('kb_version_slug'))),
|
||||
'page' => $this->renderPage((string) ($requestRoute['product'] ?? get_query_var('kb_product_slug')), (string) ($requestRoute['version'] ?? get_query_var('kb_version_slug')), trim((string) ($requestRoute['page'] ?? get_query_var('kb_page_slug')), '/')),
|
||||
default => $this->render404(),
|
||||
};
|
||||
|
||||
get_footer();
|
||||
exit;
|
||||
}
|
||||
|
||||
private function routeFromRequestUri(): array
|
||||
{
|
||||
$base = trim((string) Plugin::settings()['docs_base_slug'], '/') ?: 'docs';
|
||||
$path = (string) wp_parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
|
||||
$path = trim(rawurldecode($path), '/');
|
||||
|
||||
if ($path === $base) {
|
||||
return ['route' => 'index'];
|
||||
}
|
||||
|
||||
if (! str_starts_with($path . '/', $base . '/')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parts = array_values(array_filter(explode('/', substr($path, strlen($base))), static fn (string $part): bool => '' !== $part));
|
||||
|
||||
if (1 === count($parts)) {
|
||||
return ['route' => 'product', 'product' => sanitize_title($parts[0])];
|
||||
}
|
||||
|
||||
if (2 === count($parts)) {
|
||||
return ['route' => 'version', 'product' => sanitize_title($parts[0]), 'version' => sanitize_title($parts[1])];
|
||||
}
|
||||
|
||||
return [
|
||||
'route' => 'page',
|
||||
'product' => sanitize_title($parts[0] ?? ''),
|
||||
'version' => sanitize_title($parts[1] ?? ''),
|
||||
'page' => sanitize_title(implode('/', array_slice($parts, 2))),
|
||||
];
|
||||
}
|
||||
|
||||
private function routeFromQuery(): array
|
||||
{
|
||||
$route = sanitize_key(wp_unslash((string) ($_GET['kb_markdown_route'] ?? '')));
|
||||
|
||||
if (! $route) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'route' => $route,
|
||||
'product' => sanitize_title(wp_unslash((string) ($_GET['kb_product_slug'] ?? ''))),
|
||||
'version' => sanitize_title(wp_unslash((string) ($_GET['kb_version_slug'] ?? ''))),
|
||||
'page' => sanitize_title(wp_unslash((string) ($_GET['kb_page_slug'] ?? ''))),
|
||||
];
|
||||
}
|
||||
|
||||
public static function shortcodeDocsIndex(): string
|
||||
{
|
||||
return (new 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-markdown-importer'),
|
||||
'results' => [],
|
||||
'query' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
private function capture404(): string
|
||||
{
|
||||
status_header(404);
|
||||
return (new TemplateLoader())->capture('search', [
|
||||
'title' => __('Documentation page not found.', 'kb-markdown-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) {
|
||||
$versions = (new self())->versionsForProduct($product->slug);
|
||||
|
||||
if (! $versions) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'term' => $product,
|
||||
'versions' => $versions,
|
||||
];
|
||||
}
|
||||
|
||||
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-markdown-importer/includes/Frontend/SearchController.php
Normal file
69
kb-markdown-importer/includes/Frontend/SearchController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Frontend;
|
||||
|
||||
use KbMarkdownImporter\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-markdown-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-markdown-importer/includes/Frontend/TemplateLoader.php
Normal file
28
kb-markdown-importer/includes/Frontend/TemplateLoader.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Frontend;
|
||||
|
||||
final class TemplateLoader
|
||||
{
|
||||
public function render(string $template, array $vars = []): void
|
||||
{
|
||||
$path = KB_MARKDOWN_IMPORTER_DIR . 'templates/' . $template . '.php';
|
||||
|
||||
if (! is_readable($path)) {
|
||||
status_header(500);
|
||||
echo esc_html__('Knowledgebase template missing.', 'kb-markdown-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-markdown-importer/includes/Frontend/UrlBuilder.php
Normal file
163
kb-markdown-importer/includes/Frontend/UrlBuilder.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Frontend;
|
||||
|
||||
use KbMarkdownImporter\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_markdown_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_markdown_route'])) {
|
||||
return self::route(
|
||||
sanitize_key((string) $query['kb_markdown_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-markdown-importer/includes/GitLab/GitLabBranch.php
Normal file
13
kb-markdown-importer/includes/GitLab/GitLabBranch.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\GitLab;
|
||||
|
||||
final class GitLabBranch
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly string $commitSha = ''
|
||||
) {
|
||||
}
|
||||
}
|
||||
194
kb-markdown-importer/includes/GitLab/GitLabClient.php
Normal file
194
kb-markdown-importer/includes/GitLab/GitLabClient.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\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-markdown-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-markdown-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-markdown-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-markdown-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-markdown-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-markdown-importer/includes/GitLab/GitLabProject.php
Normal file
14
kb-markdown-importer/includes/GitLab/GitLabProject.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\GitLab;
|
||||
|
||||
final class GitLabProject
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly string $name,
|
||||
public readonly string $pathWithNamespace
|
||||
) {
|
||||
}
|
||||
}
|
||||
12
kb-markdown-importer/includes/Import/Checksum.php
Normal file
12
kb-markdown-importer/includes/Import/Checksum.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Import;
|
||||
|
||||
final class Checksum
|
||||
{
|
||||
public static function content(string $content): string
|
||||
{
|
||||
return hash('sha256', $content);
|
||||
}
|
||||
}
|
||||
14
kb-markdown-importer/includes/Import/ImportJob.php
Normal file
14
kb-markdown-importer/includes/Import/ImportJob.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Import;
|
||||
|
||||
final class ImportJob
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $projectId,
|
||||
public readonly string $branch,
|
||||
public readonly bool $dryRun = false
|
||||
) {
|
||||
}
|
||||
}
|
||||
61
kb-markdown-importer/includes/Import/ImportLogger.php
Normal file
61
kb-markdown-importer/includes/Import/ImportLogger.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Import;
|
||||
|
||||
final class ImportLogger
|
||||
{
|
||||
private const OPTION = 'kb_markdown_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_markdown_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);
|
||||
}
|
||||
}
|
||||
456
kb-markdown-importer/includes/Import/ImportManager.php
Normal file
456
kb-markdown-importer/includes/Import/ImportManager.php
Normal file
@@ -0,0 +1,456 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Import;
|
||||
|
||||
use KbMarkdownImporter\GitLab\GitLabClient;
|
||||
use KbMarkdownImporter\Markdown\MarkdownRenderer;
|
||||
use KbMarkdownImporter\Plugin;
|
||||
use KbMarkdownImporter\Repository\PageRepository;
|
||||
use KbMarkdownImporter\Repository\ProductRepository;
|
||||
use KbMarkdownImporter\Repository\VersionRepository;
|
||||
|
||||
final class ImportManager
|
||||
{
|
||||
private GitLabClient $client;
|
||||
private array $settings;
|
||||
private PageRepository $pages;
|
||||
private ProductRepository $products;
|
||||
private VersionRepository $versions;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->settings = Plugin::settings();
|
||||
$this->client = new GitLabClient($this->settings);
|
||||
$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_markdown_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_markdown_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);
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$paths = array_values(array_map(static fn (array $item): string => (string) ($item['path'] ?? ''), $tree));
|
||||
if (! in_array('doku.md', $paths, true)) {
|
||||
ImportLogger::warning('doku.md missing for ' . $projectPath . '@' . $branchName . '. Branch skipped.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$metadata = $this->loadMetadata($projectId, $branchName, $paths);
|
||||
$productName = (string) ($metadata['title'] ?? $project['name'] ?? $projectPath);
|
||||
$productSlug = sanitize_title((string) ($metadata['name'] ?? $project['path'] ?? $productName));
|
||||
$version = (string) ($metadata['version'] ?? ltrim($branchName, 'v'));
|
||||
$versionSlug = sanitize_title($version);
|
||||
$productTermId = $this->products->ensure($productName, $productSlug);
|
||||
$versionTermId = $this->versions->ensure($version);
|
||||
$pagePaths = $this->documentationPages($paths, (array) ($metadata['nav'] ?? []));
|
||||
|
||||
if (! in_array('stepbystep.md', $pagePaths, true)) {
|
||||
ImportLogger::warning('stepbystep.md missing for ' . $projectPath . '@' . $branchName . '. It should be present in every new documentation project.');
|
||||
}
|
||||
|
||||
if (! $this->hasImagesDirectory($paths)) {
|
||||
ImportLogger::warning('images/ folder missing for ' . $projectPath . '@' . $branchName . '. It should be present in every new documentation project.');
|
||||
}
|
||||
|
||||
$navTree = $this->navigationTree($pagePaths, (array) ($metadata['nav'] ?? []));
|
||||
$imageMap = $this->importImages($projectId, $branchName, $paths, $dryRun);
|
||||
$renderer = new MarkdownRenderer();
|
||||
$count = 0;
|
||||
|
||||
foreach ($pagePaths as $sourcePath) {
|
||||
$content = $this->client->getFileRaw($projectId, $sourcePath, $branchName);
|
||||
|
||||
if (is_wp_error($content)) {
|
||||
ImportLogger::warning('Page unreadable: ' . $sourcePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
$pageSlug = $this->pageSlugFromPath($sourcePath);
|
||||
$title = $this->extractTitle($content, $sourcePath);
|
||||
$navOrder = $this->navOrder($pagePaths, $sourcePath);
|
||||
$html = $renderer->render($content, [
|
||||
'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' => $productSlug,
|
||||
'component_title' => $productName,
|
||||
'version' => $version,
|
||||
'module' => '',
|
||||
'page_path' => $sourcePath,
|
||||
'source_path' => $sourcePath,
|
||||
'checksum' => Checksum::content($content . $commitSha . $this->rendererVersion()),
|
||||
'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' => $this->rendererVersion(),
|
||||
], $dryRun);
|
||||
|
||||
if ($saved || $dryRun) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function loadMetadata(string $projectId, string $branchName, array $paths): array
|
||||
{
|
||||
if (! in_array('doku.yml', $paths, true) && ! in_array('doku.yaml', $paths, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$metadataPath = in_array('doku.yml', $paths, true) ? 'doku.yml' : 'doku.yaml';
|
||||
$content = $this->client->getFileRaw($projectId, $metadataPath, $branchName);
|
||||
|
||||
if (is_wp_error($content)) {
|
||||
ImportLogger::warning($metadataPath . ' unreadable: ' . $content->get_error_message());
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->parseMetadata($content);
|
||||
}
|
||||
|
||||
private function parseMetadata(string $content): array
|
||||
{
|
||||
$data = [];
|
||||
$currentNav = null;
|
||||
|
||||
foreach (preg_split('/\R/', $content) ?: [] as $line) {
|
||||
if (preg_match('/^([a-zA-Z0-9_-]+):\s*"?([^"]*)"?\s*$/', $line, $matches)) {
|
||||
$key = strtolower($matches[1]);
|
||||
if ('nav' === $key) {
|
||||
$data['nav'] = [];
|
||||
$currentNav = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
$data[$key] = trim($matches[2]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^\s*-\s+title:\s*"?([^"]+)"?\s*$/', $line, $matches)) {
|
||||
$data['nav'] ??= [];
|
||||
$data['nav'][] = ['title' => trim($matches[1]), 'file' => ''];
|
||||
$currentNav = array_key_last($data['nav']);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null !== $currentNav && preg_match('/^\s*file:\s*"?([^"]+)"?\s*$/', $line, $matches)) {
|
||||
$data['nav'][$currentNav]['file'] = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function documentationPages(array $paths, array $nav): array
|
||||
{
|
||||
$pages = array_values(array_filter($paths, static function (string $path): bool {
|
||||
return (bool) preg_match('/^[^\/]+\.md$/i', $path) && ! in_array(strtolower($path), ['readme.md'], true);
|
||||
}));
|
||||
|
||||
$ordered = [];
|
||||
foreach ($nav as $item) {
|
||||
$file = (string) ($item['file'] ?? '');
|
||||
if ($file && in_array($file, $pages, true)) {
|
||||
$ordered[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['doku.md', 'stepbystep.md'] as $required) {
|
||||
if (in_array($required, $pages, true) && ! in_array($required, $ordered, true)) {
|
||||
$ordered[] = $required;
|
||||
}
|
||||
}
|
||||
|
||||
sort($pages, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
foreach ($pages as $page) {
|
||||
if (! in_array($page, $ordered, true)) {
|
||||
$ordered[] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
private function navigationTree(array $pagePaths, array $nav): array
|
||||
{
|
||||
$tree = [];
|
||||
|
||||
foreach ($nav as $item) {
|
||||
$file = (string) ($item['file'] ?? '');
|
||||
if (! $file || ! in_array($file, $pagePaths, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tree[] = [
|
||||
'title' => (string) ($item['title'] ?? $this->titleFromFilename($file)),
|
||||
'target' => $file,
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($pagePaths as $path) {
|
||||
if ($this->navContains($tree, $path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tree[] = [
|
||||
'title' => $this->titleFromFilename($path),
|
||||
'target' => $path,
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
private function importImages(string $projectId, string $branchName, array $paths, bool $dryRun): array
|
||||
{
|
||||
$images = [];
|
||||
$allowed = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
|
||||
|
||||
if ('1' === $this->settings['allow_svg']) {
|
||||
$allowed[] = 'svg';
|
||||
}
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (! preg_match('#^images/.+\.(' . implode('|', $allowed) . ')$#i', $path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = $dryRun ? '' : $this->importImage($projectId, $branchName, $path);
|
||||
if ($url) {
|
||||
$images[$path] = $url;
|
||||
$images[ltrim($path, '/')] = $url;
|
||||
$images[basename($path)] = $url;
|
||||
$images[preg_replace('#^images/#', '', $path) ?: basename($path)] = $url;
|
||||
}
|
||||
}
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
private function hasImagesDirectory(array $paths): bool
|
||||
{
|
||||
foreach ($paths as $path) {
|
||||
if (str_starts_with($path, 'images/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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_markdown_asset_key', $assetKey);
|
||||
update_post_meta((int) $attachmentId, '_kb_markdown_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_markdown_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(wp_strip_all_tags($matches[1]));
|
||||
}
|
||||
|
||||
return $this->titleFromFilename($fallback);
|
||||
}
|
||||
|
||||
private function titleFromFilename(string $path): string
|
||||
{
|
||||
$title = preg_replace('/\.md$/i', '', basename($path)) ?: basename($path);
|
||||
if ('doku' === strtolower($title)) {
|
||||
return __('Overview', 'kb-markdown-importer');
|
||||
}
|
||||
|
||||
return ucwords(str_replace(['-', '_'], ' ', $title));
|
||||
}
|
||||
|
||||
private function pageSlugFromPath(string $path): string
|
||||
{
|
||||
$page = preg_replace('/\.md$/i', '', basename($path)) ?: basename($path);
|
||||
return in_array(strtolower($page), ['doku', 'index'], true) ? '' : sanitize_title($page);
|
||||
}
|
||||
|
||||
private function navOrder(array $pagePaths, string $sourcePath): int
|
||||
{
|
||||
$index = array_search($sourcePath, $pagePaths, true);
|
||||
return false === $index ? 9999 : ((int) $index + 1);
|
||||
}
|
||||
|
||||
private function navContains(array $tree, string $target): bool
|
||||
{
|
||||
foreach ($tree as $node) {
|
||||
if ($target === (string) ($node['target'] ?? '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function rendererVersion(): string
|
||||
{
|
||||
return 'markdown-1';
|
||||
}
|
||||
}
|
||||
190
kb-markdown-importer/includes/Markdown/MarkdownRenderer.php
Normal file
190
kb-markdown-importer/includes/Markdown/MarkdownRenderer.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Markdown;
|
||||
|
||||
use KbMarkdownImporter\Frontend\UrlBuilder;
|
||||
|
||||
final class MarkdownRenderer
|
||||
{
|
||||
public function render(string $markdown, array $context = []): string
|
||||
{
|
||||
$lines = preg_split('/\R/', str_replace(["\r\n", "\r"], "\n", $markdown)) ?: [];
|
||||
$html = [];
|
||||
$paragraph = [];
|
||||
$listType = '';
|
||||
$codeFence = false;
|
||||
$code = [];
|
||||
|
||||
$flushParagraph = function () use (&$html, &$paragraph, $context): void {
|
||||
if (! $paragraph) {
|
||||
return;
|
||||
}
|
||||
|
||||
$html[] = '<p>' . $this->renderInline(trim(implode(' ', $paragraph)), $context) . '</p>';
|
||||
$paragraph = [];
|
||||
};
|
||||
|
||||
$closeList = function () use (&$html, &$listType): void {
|
||||
if ('' === $listType) {
|
||||
return;
|
||||
}
|
||||
|
||||
$html[] = '</' . $listType . '>';
|
||||
$listType = '';
|
||||
};
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^\s*```/', $line)) {
|
||||
if ($codeFence) {
|
||||
$html[] = '<pre><code>' . esc_html(implode("\n", $code)) . '</code></pre>';
|
||||
$code = [];
|
||||
$codeFence = false;
|
||||
} else {
|
||||
$flushParagraph();
|
||||
$closeList();
|
||||
$codeFence = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($codeFence) {
|
||||
$code[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('' === trim($line)) {
|
||||
$flushParagraph();
|
||||
$closeList();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^(#{1,6})\s+(.+)$/', $line, $matches)) {
|
||||
$flushParagraph();
|
||||
$closeList();
|
||||
$level = strlen($matches[1]);
|
||||
$text = trim($matches[2]);
|
||||
$id = sanitize_title(wp_strip_all_tags($text));
|
||||
$html[] = sprintf('<h%d id="%s">%s</h%d>', $level, esc_attr($id), $this->renderInline($text, $context), $level);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^\s*[-*]\s+(.+)$/', $line, $matches)) {
|
||||
$flushParagraph();
|
||||
if ('ul' !== $listType) {
|
||||
$closeList();
|
||||
$html[] = '<ul>';
|
||||
$listType = 'ul';
|
||||
}
|
||||
$html[] = '<li>' . $this->renderInline(trim($matches[1]), $context) . '</li>';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^\s*\d+\.\s+(.+)$/', $line, $matches)) {
|
||||
$flushParagraph();
|
||||
if ('ol' !== $listType) {
|
||||
$closeList();
|
||||
$html[] = '<ol>';
|
||||
$listType = 'ol';
|
||||
}
|
||||
$html[] = '<li>' . $this->renderInline(trim($matches[1]), $context) . '</li>';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^>\s?(.*)$/', $line, $matches)) {
|
||||
$flushParagraph();
|
||||
$closeList();
|
||||
$html[] = '<blockquote><p>' . $this->renderInline(trim($matches[1]), $context) . '</p></blockquote>';
|
||||
continue;
|
||||
}
|
||||
|
||||
$paragraph[] = trim($line);
|
||||
}
|
||||
|
||||
if ($codeFence) {
|
||||
$html[] = '<pre><code>' . esc_html(implode("\n", $code)) . '</code></pre>';
|
||||
}
|
||||
|
||||
$flushParagraph();
|
||||
$closeList();
|
||||
|
||||
return wp_kses_post(implode("\n", $html));
|
||||
}
|
||||
|
||||
private function renderInline(string $text, array $context): string
|
||||
{
|
||||
$escaped = esc_html($text);
|
||||
|
||||
$escaped = preg_replace_callback('/!\[([^\]]*)\]\(([^)]+)\)/', function (array $matches) use ($context): string {
|
||||
$alt = html_entity_decode($matches[1], ENT_QUOTES);
|
||||
$src = html_entity_decode($matches[2], ENT_QUOTES);
|
||||
$url = $this->resolveImageUrl($src, (array) ($context['images'] ?? []));
|
||||
$image = sprintf('<img src="%s" alt="%s">', esc_url($url ?: $src), esc_attr($alt));
|
||||
|
||||
if ($url && ! empty($context['lightbox'])) {
|
||||
return sprintf('<a href="%s" class="kb-lightbox">%s</a>', esc_url($url), $image);
|
||||
}
|
||||
|
||||
return $image;
|
||||
}, $escaped) ?? $escaped;
|
||||
|
||||
$escaped = preg_replace_callback('/(?<!!)\[([^\]]+)\]\(([^)]+)\)/', function (array $matches) use ($context): string {
|
||||
$label = $this->renderInline($matches[1], $context);
|
||||
$href = html_entity_decode($matches[2], ENT_QUOTES);
|
||||
$url = $this->rewriteLink($href, $context) ?: $href;
|
||||
|
||||
return sprintf('<a href="%s">%s</a>', esc_url($url), $label);
|
||||
}, $escaped) ?? $escaped;
|
||||
|
||||
$escaped = preg_replace('/`([^`]+)`/', '<code>$1</code>', $escaped) ?? $escaped;
|
||||
$escaped = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $escaped) ?? $escaped;
|
||||
$escaped = preg_replace('/\*([^*]+)\*/', '<em>$1</em>', $escaped) ?? $escaped;
|
||||
|
||||
return $escaped;
|
||||
}
|
||||
|
||||
private function rewriteLink(string $href, array $context): string
|
||||
{
|
||||
if ('' === $href || str_starts_with($href, '#') || preg_match('#^(?:https?:|mailto:|tel:)#i', $href)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = wp_parse_url($href);
|
||||
if (! is_array($parts)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$path = (string) ($parts['path'] ?? '');
|
||||
if (! preg_match('/\.md$/i', $path)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$page = preg_replace('/\.md$/i', '', basename($path)) ?: basename($path);
|
||||
$slug = in_array(strtolower($page), ['doku', 'index'], true) ? '' : sanitize_title($page);
|
||||
$fragment = isset($parts['fragment']) ? '#' . sanitize_title((string) $parts['fragment']) : '';
|
||||
|
||||
return UrlBuilder::page((string) $context['product_slug'], (string) $context['version_slug'], $slug) . $fragment;
|
||||
}
|
||||
|
||||
private function resolveImageUrl(string $src, array $images): string
|
||||
{
|
||||
if ('' === $src || preg_match('#^(?:https?:|data:)#i', $src)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$candidates = array_unique([
|
||||
$src,
|
||||
ltrim($src, '/'),
|
||||
basename($src),
|
||||
preg_replace('#^images/#', '', $src) ?: $src,
|
||||
]);
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (isset($images[$candidate])) {
|
||||
return (string) $images[$candidate];
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
272
kb-markdown-importer/includes/Plugin.php
Normal file
272
kb-markdown-importer/includes/Plugin.php
Normal file
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter;
|
||||
|
||||
use KbMarkdownImporter\Admin\SettingsPage;
|
||||
use KbMarkdownImporter\Admin\StatusPage;
|
||||
use KbMarkdownImporter\Admin\SyncPage;
|
||||
use KbMarkdownImporter\Frontend\Router;
|
||||
use KbMarkdownImporter\Frontend\SearchController;
|
||||
use KbMarkdownImporter\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('admin_enqueue_scripts', [$this, 'enqueueAdminAssets']);
|
||||
add_action('rest_api_init', [$this, 'registerRestRoutes']);
|
||||
add_filter('cron_schedules', [$this, 'addCronSchedules']);
|
||||
add_filter('upload_mimes', [$this, 'allowCssUploads']);
|
||||
add_action('kb_markdown_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_markdown_importer_cron_sync');
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
public function registerContentTypes(): void
|
||||
{
|
||||
register_post_type('kb_doc_page', [
|
||||
'labels' => [
|
||||
'name' => __('Documentation Pages', 'kb-markdown-importer'),
|
||||
'singular_name' => __('Documentation Page', 'kb-markdown-importer'),
|
||||
],
|
||||
'public' => false,
|
||||
'show_ui' => true,
|
||||
'show_in_menu' => 'kb-markdown-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-markdown-importer'),
|
||||
'singular_name' => __('Product', 'kb-markdown-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-markdown-importer'),
|
||||
'singular_name' => __('Version', 'kb-markdown-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-markdown-importer'),
|
||||
'singular_name' => __('Component', 'kb-markdown-importer'),
|
||||
],
|
||||
'public' => false,
|
||||
'show_ui' => true,
|
||||
'show_in_rest' => true,
|
||||
'hierarchical' => false,
|
||||
'rewrite' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function registerAdminPages(): void
|
||||
{
|
||||
add_menu_page(
|
||||
__('Knowledgebase', 'kb-markdown-importer'),
|
||||
__('Knowledgebase', 'kb-markdown-importer'),
|
||||
'manage_kb_docs',
|
||||
'kb-markdown-importer',
|
||||
[StatusPage::class, 'render'],
|
||||
'dashicons-welcome-learn-more',
|
||||
58
|
||||
);
|
||||
|
||||
add_submenu_page('kb-markdown-importer', __('Overview', 'kb-markdown-importer'), __('Overview', 'kb-markdown-importer'), 'manage_kb_docs', 'kb-markdown-importer', [StatusPage::class, 'render']);
|
||||
add_submenu_page('kb-markdown-importer', __('Synchronization', 'kb-markdown-importer'), __('Synchronization', 'kb-markdown-importer'), 'sync_kb_docs', 'kb-markdown-sync', [SyncPage::class, 'render']);
|
||||
add_submenu_page('kb-markdown-importer', __('Settings', 'kb-markdown-importer'), __('Settings', 'kb-markdown-importer'), 'manage_kb_docs', 'kb-markdown-settings', [SettingsPage::class, 'render']);
|
||||
}
|
||||
|
||||
public function registerRestRoutes(): void
|
||||
{
|
||||
register_rest_route('kb-markdown/v1', '/status', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [StatusPage::class, 'restStatus'],
|
||||
'permission_callback' => static fn (): bool => current_user_can('manage_kb_docs'),
|
||||
]);
|
||||
|
||||
register_rest_route('kb-markdown/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-markdown/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-markdown/v1', '/search', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [SearchController::class, 'restSearch'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
register_rest_route('kb-markdown/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_markdown_weekly'] = [
|
||||
'interval' => WEEK_IN_SECONDS,
|
||||
'display' => __('Weekly', 'kb-markdown-importer'),
|
||||
];
|
||||
|
||||
return $schedules;
|
||||
}
|
||||
|
||||
public function runCronSync(): void
|
||||
{
|
||||
(new ImportManager())->syncAll(false);
|
||||
}
|
||||
|
||||
public function enqueueFrontendAssets(): void
|
||||
{
|
||||
$settings = self::settings();
|
||||
wp_enqueue_style('kb-markdown-frontend', KB_MARKDOWN_IMPORTER_URL . 'assets/css/frontend.css', [], KB_MARKDOWN_IMPORTER_VERSION);
|
||||
$designHandle = 'kb-markdown-frontend';
|
||||
|
||||
if ('obyte' === $settings['design_theme']) {
|
||||
wp_enqueue_style('kb-markdown-theme-obyte', KB_MARKDOWN_IMPORTER_URL . 'assets/css/themes/obyte.css', ['kb-markdown-frontend'], KB_MARKDOWN_IMPORTER_VERSION);
|
||||
$designHandle = 'kb-markdown-theme-obyte';
|
||||
}
|
||||
|
||||
if (! empty($settings['custom_theme_css_url'])) {
|
||||
wp_enqueue_style('kb-markdown-custom-theme', esc_url_raw((string) $settings['custom_theme_css_url']), [$designHandle], KB_MARKDOWN_IMPORTER_VERSION);
|
||||
$designHandle = 'kb-markdown-custom-theme';
|
||||
}
|
||||
|
||||
$inlineCss = sprintf(
|
||||
'.kb-docs-wrap{--kb-accent:%1$s;--kb-radius:%2$dpx;} .kb-docs-wrap{--kb-primary:%1$s;--kb-ob-accent:%3$s;}',
|
||||
esc_html((string) $settings['design_primary_color']),
|
||||
max(0, min(32, (int) $settings['design_radius'])),
|
||||
esc_html((string) $settings['design_accent_color'])
|
||||
);
|
||||
wp_add_inline_style($designHandle, $inlineCss);
|
||||
wp_enqueue_script('kb-markdown-frontend', KB_MARKDOWN_IMPORTER_URL . 'assets/js/frontend.js', [], KB_MARKDOWN_IMPORTER_VERSION, true);
|
||||
}
|
||||
|
||||
public function enqueueAdminAssets(string $hook): void
|
||||
{
|
||||
if (! str_contains($hook, 'kb-markdown-settings')) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_media();
|
||||
wp_enqueue_script('kb-markdown-admin-settings', KB_MARKDOWN_IMPORTER_URL . 'assets/js/admin-settings.js', ['jquery'], KB_MARKDOWN_IMPORTER_VERSION, true);
|
||||
}
|
||||
|
||||
public function allowCssUploads(array $mimes): array
|
||||
{
|
||||
if (current_user_can('manage_kb_docs')) {
|
||||
$mimes['css'] = 'text/css';
|
||||
}
|
||||
|
||||
return $mimes;
|
||||
}
|
||||
|
||||
public static function settings(): array
|
||||
{
|
||||
return wp_parse_args((array) get_option('kb_markdown_importer_settings', []), Settings::defaults());
|
||||
}
|
||||
|
||||
public static function syncCronSchedule(?array $settings = null): void
|
||||
{
|
||||
$settings = $settings ?: self::settings();
|
||||
wp_clear_scheduled_hook('kb_markdown_importer_cron_sync');
|
||||
|
||||
if ('disabled' === $settings['cron_interval']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schedule = match ($settings['cron_interval']) {
|
||||
'hourly' => 'hourly',
|
||||
'daily' => 'daily',
|
||||
'weekly' => 'kb_markdown_weekly',
|
||||
default => '',
|
||||
};
|
||||
|
||||
if ($schedule && ! wp_next_scheduled('kb_markdown_importer_cron_sync')) {
|
||||
wp_schedule_event(time() + HOUR_IN_SECONDS, $schedule, 'kb_markdown_importer_cron_sync');
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureDefaultSettings(): void
|
||||
{
|
||||
if (false === get_option('kb_markdown_importer_settings', false)) {
|
||||
add_option('kb_markdown_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-markdown-importer/includes/Repository/PageRepository.php
Normal file
130
kb-markdown-importer/includes/Repository/PageRepository.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\Repository;
|
||||
|
||||
use KbMarkdownImporter\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_markdown_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_markdown_component' => $data['component'],
|
||||
'_kb_markdown_component_title' => $data['component_title'],
|
||||
'_kb_markdown_version' => $data['version'],
|
||||
'_kb_markdown_module' => $data['module'],
|
||||
'_kb_markdown_page_path' => $data['page_path'],
|
||||
'_kb_markdown_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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter\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);
|
||||
}
|
||||
}
|
||||
27
kb-markdown-importer/includes/Settings.php
Normal file
27
kb-markdown-importer/includes/Settings.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbMarkdownImporter;
|
||||
|
||||
final class Settings
|
||||
{
|
||||
public static function defaults(): array
|
||||
{
|
||||
return [
|
||||
'gitlab_base_url' => '',
|
||||
'gitlab_token' => '',
|
||||
'gitlab_group' => 'knowledgebase',
|
||||
'branch_pattern' => '^v.*',
|
||||
'docs_base_slug' => 'docs',
|
||||
'image_lightbox' => '1',
|
||||
'public_docs' => '0',
|
||||
'cron_interval' => 'disabled',
|
||||
'allow_svg' => '0',
|
||||
'design_theme' => 'obyte',
|
||||
'design_primary_color' => '#00A7E6',
|
||||
'design_accent_color' => '#F59C00',
|
||||
'design_radius' => '14',
|
||||
'custom_theme_css_url' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user