feat: rewrite schema-driven widget system
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
define('ABSPATH', __DIR__ . DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
// Minimal WordPress-compatible stubs keep the schema audit runnable without a site checkout.
|
||||
if (!function_exists('sanitize_text_field')) {
|
||||
function sanitize_text_field($value): string {
|
||||
return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? '');
|
||||
}
|
||||
}
|
||||
if (!function_exists('sanitize_textarea_field')) {
|
||||
function sanitize_textarea_field($value): string {
|
||||
return trim(strip_tags((string) $value));
|
||||
}
|
||||
}
|
||||
if (!function_exists('wp_kses_post')) {
|
||||
function wp_kses_post($value): string {
|
||||
return strip_tags((string) $value, '<a><b><strong><em><br><p><span><ul><ol><li>');
|
||||
}
|
||||
}
|
||||
if (!function_exists('esc_url_raw')) {
|
||||
function esc_url_raw($value): string {
|
||||
$value = trim((string) $value);
|
||||
return filter_var($value, FILTER_VALIDATE_URL) ? $value : '';
|
||||
}
|
||||
}
|
||||
if (!function_exists('absint')) {
|
||||
function absint($value): int {
|
||||
return abs((int) $value);
|
||||
}
|
||||
}
|
||||
if (!function_exists('sanitize_hex_color')) {
|
||||
function sanitize_hex_color($value): string {
|
||||
$value = (string) $value;
|
||||
return preg_match('/^#(?:[0-9a-fA-F]{3}){1,2}$/', $value) ? strtolower($value) : '';
|
||||
}
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use AKSRT\WidgetSystem\FieldSchema;
|
||||
use AKSRT\WidgetSystem\PostQuery;
|
||||
use AKSRT\WidgetSystem\RendererRegistry;
|
||||
use AKSRT\WidgetSystem\WidgetRegistry;
|
||||
|
||||
$errors = [];
|
||||
$expected = [
|
||||
'search', 'posts_toc', 'quote', 'icon_cards', 'video', 'embed', 'media_cards',
|
||||
'icon_cover_cards', 'partners', 'combo_info', 'text_title', 'buttons', 'layout_gap',
|
||||
'tag_cloud', 'recent_comments', 'links', 'notice', 'term_cards', 'term_aggregation',
|
||||
'hot_posts', 'post_pay', 'posts', 'tab_posts', 'oneline_posts', 'pricing_plans',
|
||||
'reviews', 'faq', 'stats', 'highlights', 'feature_intro', 'tilt_cases', 'slider',
|
||||
'slider_media_combo', 'user_card', 'author_card', 'user_ranking', 'user_list',
|
||||
];
|
||||
|
||||
$registry = WidgetRegistry::all();
|
||||
if (count($registry) !== 37) {
|
||||
$errors[] = 'Registry must contain exactly 37 widgets; got ' . count($registry) . '.';
|
||||
}
|
||||
if (array_keys($registry) !== $expected) {
|
||||
$errors[] = 'Registry slugs or ordering differ from the frozen contract.';
|
||||
}
|
||||
|
||||
$supported = FieldSchema::supported_types();
|
||||
$walk = static function (array $fields, string $owner) use (&$walk, &$errors, $supported): void {
|
||||
$ids = [];
|
||||
foreach ($fields as $field) {
|
||||
$id = (string) ($field['id'] ?? '');
|
||||
$type = (string) ($field['type'] ?? 'text');
|
||||
if (!in_array($type, $supported, true)) {
|
||||
$errors[] = $owner . ': unsupported field type ' . $type . '.';
|
||||
}
|
||||
if ($id !== '') {
|
||||
if (isset($ids[$id])) {
|
||||
$errors[] = $owner . ': duplicate field id ' . $id . '.';
|
||||
}
|
||||
$ids[$id] = true;
|
||||
}
|
||||
if (!empty($field['fields']) && is_array($field['fields'])) {
|
||||
$walk($field['fields'], $owner . '.' . ($id ?: $type));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
foreach ($registry as $slug => $definition) {
|
||||
if ($definition->id_base() !== 'aksrt_widget_' . $slug) {
|
||||
$errors[] = $slug . ': invalid id_base.';
|
||||
}
|
||||
if (preg_match('/(?:bbs|forum|shop|cart|inventory|shipping)/i', $slug . ' ' . $definition->renderer())) {
|
||||
$errors[] = $slug . ': forbidden forum/shop contract.';
|
||||
}
|
||||
$fields = $definition->fields();
|
||||
$walk($fields, $slug);
|
||||
$field_ids = array_column($fields, 'id');
|
||||
foreach (['show_type', 'show_id_type', 'show_ids', 'animation_in', 'layout_pin_pc', 'layout_bg'] as $required) {
|
||||
if (!in_array($required, $field_ids, true)) {
|
||||
$errors[] = $slug . ': missing common field ' . $required . '.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$renderers = RendererRegistry::all();
|
||||
if (count($renderers) !== 37 || array_keys($renderers) !== $expected) {
|
||||
$errors[] = 'Renderer registry must map the exact 37 frozen slugs.';
|
||||
}
|
||||
foreach ($renderers as $slug => $renderer) {
|
||||
if (!is_callable([$renderer, 'render'])) {
|
||||
$errors[] = $slug . ': renderer is not callable.';
|
||||
}
|
||||
}
|
||||
|
||||
$dynamic = ['posts', 'tab_posts', 'oneline_posts', 'hot_posts'];
|
||||
foreach ($expected as $slug) {
|
||||
if (RendererRegistry::supports_items($slug) !== in_array($slug, $dynamic, true)) {
|
||||
$errors[] = $slug . ': invalid dynamic items capability.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!defined('DAY_IN_SECONDS')) {
|
||||
define('DAY_IN_SECONDS', 86400);
|
||||
}
|
||||
$query_args = PostQuery::arguments([
|
||||
'count' => 7,
|
||||
'category_ids' => '2,5',
|
||||
'tag_ids' => '8,13',
|
||||
'limit_day' => 30,
|
||||
'orderby' => 'views',
|
||||
'order' => 'ASC',
|
||||
'pay_type' => 'video',
|
||||
], 3);
|
||||
if (($query_args['posts_per_page'] ?? 0) !== 7 || ($query_args['paged'] ?? 0) !== 3) {
|
||||
$errors[] = 'Post query pagination contract failed.';
|
||||
}
|
||||
if (($query_args['category__in'] ?? []) !== [2, 5] || ($query_args['tag__in'] ?? []) !== [8, 13]) {
|
||||
$errors[] = 'Post query taxonomy contract failed.';
|
||||
}
|
||||
if (($query_args['orderby'] ?? '') !== 'meta_value_num' || ($query_args['order'] ?? '') !== 'ASC') {
|
||||
$errors[] = 'Post query ordering contract failed.';
|
||||
}
|
||||
$pay_query = json_encode($query_args['meta_query'] ?? []);
|
||||
if (!is_string($pay_query) || !str_contains($pay_query, 'video')) {
|
||||
$errors[] = 'Post query paid-content contract failed.';
|
||||
}
|
||||
|
||||
$rest_source = (string) file_get_contents(dirname(__DIR__) . '/src/WidgetSystem/RestController.php');
|
||||
foreach ([
|
||||
"'permission_callback' => '__return_true'",
|
||||
"'instance' => ['type' => 'string', 'required' => true",
|
||||
"'page' => ['type' => 'integer'",
|
||||
"'segment' => ['type' => 'string'",
|
||||
'wp_get_sidebars_widgets()',
|
||||
'$widget->get_settings()',
|
||||
] as $contract) {
|
||||
if (!str_contains($rest_source, $contract)) {
|
||||
$errors[] = 'REST controller missing contract: ' . $contract;
|
||||
}
|
||||
}
|
||||
if (preg_match('/\$_(?:GET|POST|REQUEST)/', $rest_source)) {
|
||||
$errors[] = 'REST controller must use WP_REST_Request only.';
|
||||
}
|
||||
|
||||
$frontend_files = [
|
||||
dirname(__DIR__) . '/assets/widgets/frontend.css',
|
||||
dirname(__DIR__) . '/assets/widgets/frontend.js',
|
||||
];
|
||||
foreach ($frontend_files as $frontend_file) {
|
||||
if (!is_file($frontend_file) || filesize($frontend_file) === 0) {
|
||||
$errors[] = 'Missing frontend widget asset: ' . basename($frontend_file);
|
||||
}
|
||||
}
|
||||
|
||||
$posts = $registry['posts'];
|
||||
$normalized = $posts->normalize([
|
||||
'title' => '<script>alert(1)</script> 最新课程',
|
||||
'show_type' => 'invalid',
|
||||
'show_id_type' => 'show',
|
||||
'show_ids' => '3, 3,9 bad -4',
|
||||
'count' => 999,
|
||||
'style' => 'broken',
|
||||
'pay_type' => 'video',
|
||||
]);
|
||||
if ($normalized['count'] !== 50 || $normalized['style'] !== 'card' || $normalized['show_type'] !== 'all') {
|
||||
$errors[] = 'Schema clamping or enum fallback failed.';
|
||||
}
|
||||
if ($normalized['show_ids'] !== '3,9,4') {
|
||||
$errors[] = 'ID list normalization failed: ' . $normalized['show_ids'];
|
||||
}
|
||||
if (str_contains($normalized['title'], '<script>')) {
|
||||
$errors[] = 'Text sanitization failed.';
|
||||
}
|
||||
|
||||
$baseline_file = dirname(__DIR__) . '/.specs/baselines/widget-reference.json';
|
||||
$baseline = json_decode((string) file_get_contents($baseline_file), true);
|
||||
if (!is_array($baseline) || ($baseline['source_policy']['modern_widget_count'] ?? null) !== 37) {
|
||||
$errors[] = 'Reference baseline is missing or invalid.';
|
||||
} elseif (is_dir((string) ($baseline['source_root'] ?? ''))) {
|
||||
foreach ((array) ($baseline['files'] ?? []) as $file) {
|
||||
$path = rtrim((string) $baseline['source_root'], '\\/') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $file['path']);
|
||||
if (!is_file($path)) {
|
||||
$errors[] = 'Reference file missing: ' . $path;
|
||||
continue;
|
||||
}
|
||||
if (hash_file('sha256', $path) !== ($file['sha256'] ?? '')) {
|
||||
$errors[] = 'Reference file changed: ' . $file['path'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
fwrite(STDERR, "Widget system audit failed:\n- " . implode("\n- ", $errors) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Widget system audit passed: 37 definitions/renderers, frozen baseline, query/REST/assets contracts.\n";
|
||||
Reference in New Issue
Block a user