Style anpassungen für die Dokumentationsseite und die Produktseite. Hinzufügen eines Feeds für Produktaktualisierungen. Aktualisierung der Router- und Suchcontroller-Logik, um die neuen Seiten zu unterstützen. Anpassung der Admin-Einstellungen für die Dokumentationsseite.

This commit is contained in:
Sven Steinert
2026-05-20 15:07:59 +02:00
parent 3ff9146a63
commit 1d4cf6e727
17 changed files with 737 additions and 65 deletions

View File

@@ -11,7 +11,7 @@ final class BreadcrumbBuilder
{
$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')),
sprintf('<a href="%s">%s</a>', esc_url(home_url('/' . $base . '/')), esc_html__('Dokumentation', 'kb-markdown-importer')),
];
$path = $base;

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace KbMarkdownImporter\Frontend;
use KbMarkdownImporter\Plugin;
final class ProductUpdatesFeed
{
public static function items(array $settings = []): array
{
$settings = $settings ?: Plugin::settings();
$source = (string) ($settings['product_updates_source'] ?? 'rss');
$url = esc_url_raw((string) ('rest' === $source ? ($settings['product_updates_rest_url'] ?? '') : ($settings['product_updates_feed_url'] ?? '')));
if ('' === $url) {
return [];
}
$cacheKey = 'kb_product_updates_' . md5($source . $url . wp_json_encode([
$settings['product_updates_feed_item_path'] ?? '',
$settings['product_updates_feed_product_field'] ?? '',
$settings['product_updates_feed_version_field'] ?? '',
$settings['product_updates_feed_date_field'] ?? '',
$settings['product_updates_feed_changelog_field'] ?? '',
$settings['product_updates_rest_list_path'] ?? '',
$settings['product_updates_rest_product_field'] ?? '',
$settings['product_updates_rest_version_field'] ?? '',
$settings['product_updates_rest_date_field'] ?? '',
$settings['product_updates_rest_changelog_field'] ?? '',
]));
$cached = get_transient($cacheKey);
if (is_array($cached)) {
return $cached;
}
$response = wp_remote_get($url, [
'timeout' => 8,
'redirection' => 3,
'user-agent' => 'KB Markdown Importer/' . KB_MARKDOWN_IMPORTER_VERSION,
]);
if (is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)) {
return [];
}
$body = (string) wp_remote_retrieve_body($response);
$items = 'rest' === $source ? self::parseJson($body, $settings) : self::parseXml($body, $settings);
set_transient($cacheKey, $items, 15 * MINUTE_IN_SECONDS);
return $items;
}
private static function parseXml(string $xml, array $settings): array
{
if ('' === trim($xml) || ! class_exists(\DOMDocument::class)) {
return [];
}
$previous = libxml_use_internal_errors(true);
$document = new \DOMDocument();
$loaded = $document->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (! $loaded) {
return [];
}
$xpath = new \DOMXPath($document);
$itemNodes = $xpath->query(self::itemPath((string) ($settings['product_updates_feed_item_path'] ?? 'channel/item')));
if (! $itemNodes) {
return [];
}
$limit = max(1, min(20, (int) ($settings['product_updates_feed_limit'] ?? 5)));
$items = [];
foreach ($itemNodes as $itemNode) {
if (count($items) >= $limit) {
break;
}
$date = self::fieldValue($xpath, $itemNode, (string) ($settings['product_updates_feed_date_field'] ?? 'pubDate'));
$items[] = [
'product' => self::fieldValue($xpath, $itemNode, (string) ($settings['product_updates_feed_product_field'] ?? 'title')),
'version' => self::fieldValue($xpath, $itemNode, (string) ($settings['product_updates_feed_version_field'] ?? 'category')),
'date' => self::formatDate($date),
'changelog' => self::fieldValue($xpath, $itemNode, (string) ($settings['product_updates_feed_changelog_field'] ?? 'description')),
];
}
return $items;
}
private static function parseJson(string $json, array $settings): array
{
$data = json_decode($json, true);
if (! is_array($data)) {
return [];
}
$nodes = self::jsonList($data, (string) ($settings['product_updates_rest_list_path'] ?? 'content,data,items'));
if (! $nodes) {
return [];
}
$limit = max(1, min(20, (int) ($settings['product_updates_feed_limit'] ?? 5)));
$items = [];
foreach ($nodes as $node) {
if (count($items) >= $limit || ! is_array($node)) {
break;
}
$date = self::jsonField($node, (string) ($settings['product_updates_rest_date_field'] ?? 'releaseDate,date,updatedAt,createdAt'));
$items[] = [
'product' => self::jsonField($node, (string) ($settings['product_updates_rest_product_field'] ?? 'product.name,productName,name')),
'version' => self::jsonField($node, (string) ($settings['product_updates_rest_version_field'] ?? 'version,versionName,name')),
'date' => self::formatDate($date),
'changelog' => self::jsonField($node, (string) ($settings['product_updates_rest_changelog_field'] ?? 'changelog,changeLog,description,changes')),
];
}
return $items;
}
private static function jsonList(array $data, string $paths): array
{
foreach (self::pathAlternatives($paths) as $path) {
$value = '' === $path ? $data : self::jsonValue($data, $path);
if (is_array($value) && array_is_list($value)) {
return $value;
}
}
return array_is_list($data) ? $data : [];
}
private static function jsonField(array $data, string $paths): string
{
foreach (self::pathAlternatives($paths) as $path) {
$value = self::jsonValue($data, $path);
if (is_scalar($value)) {
return trim(wp_strip_all_tags((string) $value));
}
if (is_array($value)) {
$text = implode(', ', array_filter(array_map(static fn ($item): string => is_scalar($item) ? (string) $item : '', $value)));
if ('' !== $text) {
return trim(wp_strip_all_tags($text));
}
}
}
return '';
}
private static function jsonValue(array $data, string $path): mixed
{
$value = $data;
foreach (self::pathSegments($path) as $segment) {
if (! is_array($value) || ! array_key_exists($segment, $value)) {
return null;
}
$value = $value[$segment];
}
return $value;
}
private static function pathAlternatives(string $paths): array
{
return array_values(array_map('trim', explode(',', $paths)));
}
private static function itemPath(string $path): string
{
$segments = self::pathSegments($path ?: 'channel/item');
$first = (string) array_shift($segments);
$query = '//*[local-name()="' . self::localName($first ?: 'item') . '"]';
foreach ($segments as $segment) {
$query .= '/*[local-name()="' . self::localName((string) $segment) . '"]';
}
return $query;
}
private static function fieldValue(\DOMXPath $xpath, \DOMNode $node, string $path): string
{
$segments = self::pathSegments($path);
if (! $segments) {
return '';
}
$attribute = null;
$last = (string) end($segments);
if (str_starts_with($last, '@')) {
$attribute = substr($last, 1);
array_pop($segments);
}
$query = '.';
foreach ($segments as $segment) {
$query .= '/*[local-name()="' . self::localName((string) $segment) . '"]';
}
$result = $xpath->query($query, $node);
$target = $result && $result->length > 0 ? $result->item(0) : null;
if (! $target) {
return '';
}
if ($attribute && $target instanceof \DOMElement) {
return trim(wp_strip_all_tags(html_entity_decode($target->getAttribute($attribute), ENT_QUOTES | ENT_XML1, get_bloginfo('charset'))));
}
return trim(wp_strip_all_tags(html_entity_decode($target->textContent, ENT_QUOTES | ENT_XML1, get_bloginfo('charset'))));
}
private static function pathSegments(string $path): array
{
$path = trim(str_replace('.', '/', $path), '/ ');
if ('' === $path) {
return [];
}
return array_values(array_filter(array_map('trim', explode('/', $path)), static fn (string $segment): bool => '' !== $segment));
}
private static function localName(string $segment): string
{
$segment = trim($segment);
$parts = explode(':', $segment);
return preg_replace('/[^A-Za-z0-9_-]/', '', (string) end($parts)) ?: '';
}
private static function formatDate(string $date): string
{
if ('' === $date) {
return '';
}
$timestamp = strtotime($date);
if (! $timestamp) {
return $date;
}
return wp_date((string) get_option('date_format'), $timestamp);
}
}

View File

@@ -194,9 +194,13 @@ final class Router
private function captureIndex(): string
{
$settings = Plugin::settings();
return (new TemplateLoader())->capture('documentation-index', [
'products' => self::productsWithVersions(),
'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'),
'settings' => $settings,
'updates' => ProductUpdatesFeed::items($settings),
'base_slug' => trim((string) $settings['docs_base_slug'], '/'),
'url_builder' => UrlBuilder::class,
]);
}
@@ -245,6 +249,7 @@ final class Router
return (new TemplateLoader())->capture('version', [
'product' => $product,
'version' => $version,
'versions' => $this->versionsForProduct($productSlug),
'pages' => $this->pagesForVersion($productSlug, $versionSlug),
'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'),
'url_builder' => UrlBuilder::class,
@@ -332,7 +337,7 @@ final class Router
{
status_header(404);
(new TemplateLoader())->render('search', [
'title' => __('Documentation page not found.', 'kb-markdown-importer'),
'title' => __('Dokumentationsseite nicht gefunden.', 'kb-markdown-importer'),
'results' => [],
'query' => '',
]);
@@ -342,7 +347,7 @@ final class Router
{
status_header(404);
return (new TemplateLoader())->capture('search', [
'title' => __('Documentation page not found.', 'kb-markdown-importer'),
'title' => __('Dokumentationsseite nicht gefunden.', 'kb-markdown-importer'),
'results' => [],
'query' => '',
]);

View File

@@ -17,7 +17,7 @@ final class SearchController
$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'),
'title' => __('Dokumentation durchsuchen', 'kb-markdown-importer'),
'query' => $query,
'results' => $results,
]);

View File

@@ -11,7 +11,7 @@ final class TemplateLoader
if (! is_readable($path)) {
status_header(500);
echo esc_html__('Knowledgebase template missing.', 'kb-markdown-importer');
echo esc_html__('Dokumentationsvorlage fehlt.', 'kb-markdown-importer');
return;
}