feat: rewrite schema-driven widget system

This commit is contained in:
2026-08-17 17:29:54 +08:00
parent 6c6c462df8
commit ad99ab53ce
28 changed files with 3489 additions and 0 deletions
+218
View File
@@ -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";