chore: complete M1.1 runtime QA and release packaging

This commit is contained in:
XShop
2026-09-13 19:04:41 +03:30
commit f11e1d650d
66 changed files with 3094 additions and 0 deletions
@@ -0,0 +1,113 @@
<?php
/**
* Breadcrumbs — semantic, RTL/LTR safe, Woo-aware, SEO-friendly.
*
* Data: array of ['label'=>string,'url'=>string|null,'current'=>bool]
* If $args['items'] not provided, auto-builds from WP/WC context.
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$args = wp_parse_args($args ?? [], ['items' => null]);
$items = $args['items'];
if ($items === null) {
$items = [];
// Home.
$items[] = ['label' => esc_html__('Home', 'xshop'), 'url' => home_url('/'), 'current' => false];
if (is_front_page()) {
// Front page is current; keep only home as current.
$items[0]['current'] = true;
$items[0]['url'] = null;
} elseif (function_exists('is_woocommerce') && xshop_has_woocommerce() && (is_woocommerce() || is_cart() || is_checkout() || is_account_page())) {
// Delegate to Woo breadcrumb if available, but provide fallback.
if (function_exists('is_shop') && is_shop()) {
$items[] = ['label' => esc_html__('Shop', 'xshop'), 'url' => null, 'current' => true];
} elseif (function_exists('is_product_category') && is_product_category()) {
$shopUrl = function_exists('wc_get_page_permalink') ? wc_get_page_permalink('shop') : home_url('/');
$items[] = ['label' => esc_html__('Shop', 'xshop'), 'url' => $shopUrl, 'current' => false];
$term = get_queried_object();
if ($term instanceof WP_Term) {
$items[] = ['label' => $term->name, 'url' => null, 'current' => true];
}
} elseif (function_exists('is_product') && is_product()) {
$shopUrl = function_exists('wc_get_page_permalink') ? wc_get_page_permalink('shop') : home_url('/');
$items[] = ['label' => esc_html__('Shop', 'xshop'), 'url' => $shopUrl, 'current' => false];
$items[] = ['label' => get_the_title(), 'url' => null, 'current' => true];
} elseif (function_exists('is_cart') && is_cart()) {
$items[] = ['label' => esc_html__('Cart', 'xshop'), 'url' => null, 'current' => true];
} elseif (function_exists('is_checkout') && is_checkout()) {
$items[] = ['label' => esc_html__('Checkout', 'xshop'), 'url' => null, 'current' => true];
} elseif (function_exists('is_account_page') && is_account_page()) {
$items[] = ['label' => esc_html__('My Account', 'xshop'), 'url' => null, 'current' => true];
}
} elseif (is_home()) {
$postsPage = get_option('page_for_posts');
$label = $postsPage ? get_the_title((int) $postsPage) : esc_html__('Blog', 'xshop');
$items[] = ['label' => $label, 'url' => null, 'current' => true];
} elseif (is_category() || is_tag() || is_tax()) {
$term = get_queried_object();
if ($term instanceof WP_Term) {
$items[] = ['label' => $term->name, 'url' => null, 'current' => true];
}
} elseif (is_search()) {
$items[] = ['label' => sprintf(esc_html__('Search: %s', 'xshop'), get_search_query()), 'url' => null, 'current' => true];
} elseif (is_404()) {
$items[] = ['label' => esc_html__('Not Found', 'xshop'), 'url' => null, 'current' => true];
} elseif (is_singular()) {
$postType = get_post_type();
if ($postType && $postType !== 'post' && $postType !== 'page') {
$obj = get_post_type_object($postType);
if ($obj) {
$items[] = ['label' => $obj->labels->name, 'url' => null, 'current' => false];
}
} elseif ($postType === 'post') {
$cat = get_the_category();
if (!empty($cat[0])) {
$items[] = ['label' => $cat[0]->name, 'url' => get_category_link($cat[0]->term_id), 'current' => false];
}
}
$items[] = ['label' => get_the_title(), 'url' => null, 'current' => true];
} elseif (is_archive()) {
$items[] = ['label' => get_the_archive_title(), 'url' => null, 'current' => true];
}
// Mark last as current if none marked.
$hasCurrent = false;
foreach ($items as $it) { if (!empty($it['current'])) { $hasCurrent = true; break; } }
if (!$hasCurrent && count($items) > 0) {
$items[count($items) - 1]['current'] = true;
$items[count($items) - 1]['url'] = null;
}
}
// Never break if empty.
if (empty($items)) { return; }
// Allow SEO plugins / child themes to filter.
$items = apply_filters('xshop_breadcrumbs_items', $items);
if (empty($items) || !is_array($items)) { return; }
?>
<nav class="xshop-breadcrumbs" aria-label="<?php esc_attr_e('Breadcrumb', 'xshop'); ?>">
<ol>
<?php foreach ($items as $item):
$label = isset($item['label']) ? (string) $item['label'] : '';
if ($label === '') { continue; }
$isCurrent = !empty($item['current']);
$url = isset($item['url']) ? (string) $item['url'] : '';
?>
<li>
<?php if ($isCurrent || $url === ''): ?>
<span aria-current="page"><?php echo esc_html($label); ?></span>
<?php else: ?>
<a href="<?php echo esc_url($url); ?>"><?php echo esc_html($label); ?></a>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ol>
</nav>
@@ -0,0 +1,23 @@
<?php
/**
* Empty state — no posts / no results.
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$title = $args['title'] ?? esc_html__('Nothing found', 'xshop');
$text = $args['text'] ?? esc_html__('Try adjusting your search or browse categories.', 'xshop');
$ctaLabel = $args['cta_label'] ?? '';
$ctaUrl = $args['cta_url'] ?? '';
?>
<div class="xshop-empty" role="status">
<h2 class="xshop-empty__title"><?php echo esc_html($title); ?></h2>
<p class="xshop-empty__text"><?php echo esc_html($text); ?></p>
<?php if ($ctaLabel !== '' && $ctaUrl !== ''): ?>
<a class="xshop-btn xshop-btn--primary" href="<?php echo esc_url($ctaUrl); ?>"><?php echo esc_html($ctaLabel); ?></a>
<?php endif; ?>
</div>
@@ -0,0 +1,27 @@
<?php
/**
* Hero foundation — extensible, no hardcoded business copy beyond fallbacks.
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$title = $args['title'] ?? esc_html__('Welcome to XShop', 'xshop');
$text = $args['text'] ?? esc_html__('A fast, RTL-ready store foundation. Customize hero content via front-page.php or a child theme.', 'xshop');
$ctaLabel = $args['cta_label'] ?? '';
$ctaUrl = $args['cta_url'] ?? '';
?>
<section class="xshop-hero" aria-label="<?php esc_attr_e('Hero', 'xshop'); ?>">
<h1 class="xshop-hero__title"><?php echo esc_html($title); ?></h1>
<?php if ($text !== ''): ?>
<p class="xshop-hero__text"><?php echo esc_html($text); ?></p>
<?php endif; ?>
<?php if ($ctaLabel !== '' && $ctaUrl !== ''): ?>
<div class="xshop-hero__actions">
<a class="xshop-btn xshop-btn--primary" href="<?php echo esc_url($ctaUrl); ?>"><?php echo esc_html($ctaLabel); ?></a>
</div>
<?php endif; ?>
</section>
@@ -0,0 +1,17 @@
<?php
/**
* Loading indicator — for future AJAX use; static markup safe for M1.
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$label = $args['label'] ?? esc_html__('Loading…', 'xshop');
?>
<div class="xshop-loading" role="status" aria-live="polite" aria-busy="true">
<span class="xshop-loading__spinner" aria-hidden="true"></span>
<span><?php echo esc_html($label); ?></span>
</div>
@@ -0,0 +1,41 @@
<?php
/**
* Pagination — archive/search, accessible, RTL-safe.
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
global $wp_query;
$query = $args['query'] ?? $wp_query;
if (!$query instanceof WP_Query) { return; }
if ((int) $query->max_num_pages <= 1) { return; }
$big = 999999999;
$links = paginate_links([
'base' => str_replace((string) $big, '%#%', esc_url(get_pagenum_link((string) $big))),
'format' => '?paged=%#%',
'current' => max(1, (int) get_query_var('paged', 1)),
'total' => (int) $query->max_num_pages,
'prev_text' => is_rtl() ? esc_html__('Next', 'xshop') : esc_html__('Previous', 'xshop'),
'next_text' => is_rtl() ? esc_html__('Previous', 'xshop') : esc_html__('Next', 'xshop'),
'type' => 'array',
'mid_size' => 1,
'end_size' => 1,
]);
if (empty($links) || !is_array($links)) { return; }
?>
<nav class="xshop-pagination" aria-label="<?php esc_attr_e('Pagination', 'xshop'); ?>">
<div class="nav-links">
<?php foreach ($links as $link):
// paginate_links returns escaped HTML; we allow it via wp_kses with minimal allow-list
// but current span vs anchor is already safe. Use wp_kses_post to preserve classes.
echo wp_kses_post($link);
endforeach; ?>
</div>
</nav>
@@ -0,0 +1,45 @@
<?php
/**
* Post Card — reusable, handles missing thumb/excerpt/long title.
*
* Expects $args['post_id'] or uses global $post.
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$postId = isset($args['post_id']) ? (int) $args['post_id'] : get_the_ID();
if (!$postId) { return; }
$title = get_the_title($postId);
if ($title === '') { $title = esc_html__('Untitled', 'xshop'); }
$permalink = get_permalink($postId);
$excerpt = has_excerpt($postId) ? get_the_excerpt($postId) : wp_trim_words(wp_strip_all_tags(get_post_field('post_content', $postId)), 22);
$hasThumb = has_post_thumbnail($postId);
?>
<article <?php post_class('xshop-card'); ?>>
<a href="<?php echo esc_url($permalink); ?>" class="xshop-card__media <?php echo $hasThumb ? '' : 'xshop-card__media--placeholder'; ?>" aria-hidden="<?php echo $hasThumb ? 'false' : 'true'; ?>" tabindex="-1">
<?php if ($hasThumb): ?>
<?php echo get_the_post_thumbnail($postId, 'xshop-card', ['loading' => 'lazy', 'decoding' => 'async', 'alt' => esc_attr($title)]); ?>
<?php else: ?>
<span><?php esc_html_e('No image', 'xshop'); ?></span>
<?php endif; ?>
</a>
<div class="xshop-card__body">
<h3 class="xshop-card__title"><a href="<?php echo esc_url($permalink); ?>"><?php echo esc_html($title); ?></a></h3>
<div class="xshop-card__meta">
<time datetime="<?php echo esc_attr(get_the_date('c', $postId)); ?>"><?php echo esc_html(get_the_date('', $postId)); ?></time>
<?php
$cats = get_the_category($postId);
if (!empty($cats[0])) {
echo '<span aria-hidden="true">·</span><a href="' . esc_url(get_category_link($cats[0]->term_id)) . '">' . esc_html($cats[0]->name) . '</a>';
}
?>
</div>
<?php if ($excerpt !== ''): ?>
<p class="xshop-card__excerpt"><?php echo esc_html($excerpt); ?></p>
<?php endif; ?>
</div>
</article>
@@ -0,0 +1,26 @@
<?php
/**
* Promotional banner foundation — rendered via banner CPT later; static variant for M1.
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$title = $args['title'] ?? '';
$text = $args['text'] ?? '';
$ctaLabel = $args['cta_label'] ?? '';
$ctaUrl = $args['cta_url'] ?? '';
if ($title === '' && $text === '') { return; }
?>
<section class="xshop-promo" aria-label="<?php esc_attr_e('Promotion', 'xshop'); ?>">
<div>
<?php if ($title !== ''): ?><h2 class="xshop-section__title" style="font-size:var(--xshop-text-xl);margin:0 0 var(--xshop-space-1)"><?php echo esc_html($title); ?></h2><?php endif; ?>
<?php if ($text !== ''): ?><p style="margin:0;color:var(--xshop-muted)"><?php echo esc_html($text); ?></p><?php endif; ?>
</div>
<?php if ($ctaLabel !== '' && $ctaUrl !== ''): ?>
<a class="xshop-btn xshop-btn--primary" href="<?php echo esc_url($ctaUrl); ?>"><?php echo esc_html($ctaLabel); ?></a>
<?php endif; ?>
</section>
@@ -0,0 +1,20 @@
<?php
/**
* Search form — accessible, RTL-safe.
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$uid = 'xshop-search-' . wp_unique_id();
$placeholder = $args['placeholder'] ?? esc_html__('Search…', 'xshop');
$action = $args['action'] ?? home_url('/');
?>
<form role="search" method="get" action="<?php echo esc_url($action); ?>" class="xshop-search-form">
<label class="xshop-sr-only" for="<?php echo esc_attr($uid); ?>"><?php esc_html_e('Search', 'xshop'); ?></label>
<input type="search" id="<?php echo esc_attr($uid); ?>" name="s" value="<?php echo esc_attr(get_search_query()); ?>" placeholder="<?php echo esc_attr($placeholder); ?>" required />
<button type="submit" class="xshop-btn xshop-btn--primary"><?php esc_html_e('Search', 'xshop'); ?></button>
</form>
@@ -0,0 +1,25 @@
<?php
/**
* Section Heading — reusable for home sections.
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$title = $args['title'] ?? '';
if ($title === '') { return; }
$linkLabel = $args['link_label'] ?? '';
$linkUrl = $args['link_url'] ?? '';
$tag = $args['tag'] ?? 'h2';
$allowed = ['h2','h3','div'];
if (!in_array($tag, $allowed, true)) { $tag = 'h2'; }
?>
<div class="xshop-section__header">
<<?php echo esc_attr($tag); ?> class="xshop-section__title"><?php echo esc_html($title); ?></<?php echo esc_attr($tag); ?>>
<?php if ($linkLabel !== '' && $linkUrl !== ''): ?>
<a class="xshop-section__link" href="<?php echo esc_url($linkUrl); ?>"><?php echo esc_html($linkLabel); ?></a>
<?php endif; ?>
</div>
@@ -0,0 +1,53 @@
<?php
/**
* Site Footer — widgets, nav, branding fallback, copyright.
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$branding = xshop_get_branding_fallback();
$year = date_i18n('Y');
?>
<div class="xshop-container">
<div class="xshop-footer__widgets">
<?php for ($i = 1; $i <= 4; $i++):
$id = 'footer-' . $i;
$has = is_active_sidebar($id);
?>
<div class="xshop-footer__col">
<?php if ($has): ?>
<?php dynamic_sidebar($id); ?>
<?php else: ?>
<section class="xshop-widget">
<h3 class="xshop-widget__title"><?php echo esc_html(sprintf(esc_html__('Footer %d', 'xshop'), $i)); ?></h3>
<p class="xshop-text-muted" style="font-size:var(--xshop-text-sm)"><?php esc_html_e('Add widgets in Appearance → Widgets.', 'xshop'); ?></p>
</section>
<?php endif; ?>
</div>
<?php endfor; ?>
</div>
<div class="xshop-footer__bottom">
<p style="margin:0">
<?php
// Translators: %1$s year, %2$s site name.
printf(esc_html__('© %1$s %2$s. All rights reserved.', 'xshop'), esc_html($year), esc_html($branding['name']));
?>
</p>
<nav aria-label="<?php esc_attr_e('Footer', 'xshop'); ?>">
<?php
wp_nav_menu([
'theme_location' => 'xshop-footer',
'container' => false,
'menu_class' => 'xshop-footer__nav-list',
'depth' => 1,
'fallback_cb' => false,
]);
?>
</nav>
</div>
</div>
@@ -0,0 +1,80 @@
<?php
/**
* Site Header — branding, primary nav, mobile nav, search, WC hooks.
* Used by header.php. Future Header Builder will extend, not replace.
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$branding = xshop_get_branding_fallback();
$hasLogo = has_custom_logo();
?>
<div class="xshop-header__inner xshop-container">
<div class="xshop-header__branding">
<?php if ($hasLogo): ?>
<div class="xshop-header__logo"><?php the_custom_logo(); ?></div>
<?php endif; ?>
<div>
<p class="xshop-header__title"><a href="<?php echo esc_url(home_url('/')); ?>" rel="home"><?php echo esc_html($branding['name']); ?></a></p>
<?php if ($branding['desc'] !== ''): ?>
<p class="xshop-header__tagline"><?php echo esc_html($branding['desc']); ?></p>
<?php endif; ?>
</div>
</div>
<nav class="xshop-header__nav xshop-nav xshop-nav--desktop" aria-label="<?php esc_attr_e('Primary', 'xshop'); ?>">
<?php
wp_nav_menu([
'theme_location' => 'xshop-primary',
'container' => false,
'menu_class' => 'xshop-nav__list',
'fallback_cb' => static function (): void {
echo '<ul class="xshop-nav__list"><li><a href="' . esc_url(home_url('/')) . '">' . esc_html__('Home', 'xshop') . '</a></li></ul>';
},
'depth' => 2,
]);
?>
</nav>
<div class="xshop-header__actions">
<div class="xshop-header__search xshop-header__search--desktop">
<?php get_template_part('template-parts/components/search-form', null, ['placeholder' => esc_html__('Search…', 'xshop')]); ?>
</div>
<?php if (xshop_has_woocommerce()): ?>
<a class="xshop-header__action-btn" href="<?php echo esc_url(function_exists('wc_get_page_permalink') ? wc_get_page_permalink('myaccount') : wp_login_url()); ?>" aria-label="<?php esc_attr_e('My Account', 'xshop'); ?>">
<span aria-hidden="true">◉</span><span class="xshop-sr-only"><?php esc_html_e('My Account', 'xshop'); ?></span>
</a>
<?php if (function_exists('wc_get_cart_url')): ?>
<a class="xshop-header__action-btn" href="<?php echo esc_url(wc_get_cart_url()); ?>" aria-label="<?php esc_attr_e('Cart', 'xshop'); ?>">
<span aria-hidden="true">🛒</span><span class="xshop-sr-only"><?php esc_html_e('Cart', 'xshop'); ?></span>
</a>
<?php endif; ?>
<?php endif; ?>
<button type="button" class="xshop-mobile-toggle" data-xshop-mobile-toggle aria-expanded="false" aria-controls="xshop-mobile-nav" aria-label="<?php esc_attr_e('Toggle menu', 'xshop'); ?>">
<span aria-hidden="true">☰</span>
</button>
</div>
</div>
<nav id="xshop-mobile-nav" class="xshop-mobile-nav xshop-container" aria-label="<?php esc_attr_e('Mobile', 'xshop'); ?>" hidden>
<?php
wp_nav_menu([
'theme_location' => 'xshop-primary',
'container' => false,
'menu_class' => 'xshop-mobile-nav__list',
'fallback_cb' => static function (): void {
echo '<ul class="xshop-mobile-nav__list"><li><a href="' . esc_url(home_url('/')) . '">' . esc_html__('Home', 'xshop') . '</a></li></ul>';
},
'depth' => 2,
]);
?>
<div style="margin-block-start:var(--xshop-space-4)">
<?php get_template_part('template-parts/components/search-form', null, ['placeholder' => esc_html__('Search…', 'xshop')]); ?>
</div>
</nav>