diff --git a/.distignore b/.distignore index 9d7694ecf4..27b54024f7 100644 --- a/.distignore +++ b/.distignore @@ -87,3 +87,4 @@ DEVELOPMENT.md TESTING.md tsconfig.json docs/* +AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..cc33d1026b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,129 @@ +# Agent workflow + +## Project Overview + +Neve is an open-source WordPress theme. It uses a hybrid PHP/React architecture: traditional WordPress PHP theme code with React-based admin interfaces (dashboard, customizer controls, metabox editor). + +## Build Commands + +```bash +# Install dependencies +yarn install --frozen-lockfile +COMPOSER=composer-dev.json composer install # or: yarn run composer:install + +# Full production build (all apps + rollup + grunt + translations) +yarn run build + +# Build individual apps +yarn run build:dash # Dashboard React app +yarn run build:customizer # Customizer controls React app +yarn run build:metabox # Metabox editor React app +yarn run build:ui-components # Shared React component library (@neve-wp/components) +yarn run build:rollup # Frontend vanilla JS (Rollup → modern + legacy bundles) +yarn run build:grunt # SCSS → CSS compilation + +# Watch mode (all apps) +yarn run dev +``` + +## Linting & Formatting + +```bash +# PHP +composer run-script phpcs # WordPress Coding Standards check +composer run-script format # Auto-fix PHP style +composer run-script phpstan # Static analysis (level 6) + +# JavaScript/TypeScript (per-app linting) +yarn run lint:global # Frontend JS (assets/js/src/) +yarn run lint:dash # Dashboard app +yarn run lint:customizer # Customizer app +yarn run format:global # Auto-fix frontend JS +yarn run format:dash # Auto-fix dashboard JS + +# SCSS +yarn run lint:scss +yarn run format:scss +``` + +## Testing + +```bash +# PHP unit tests +./vendor/bin/phpunit +./vendor/bin/phpunit tests/test-neve.php # Single test file + +# E2E tests (Playwright, requires WordPress environment) +yarn run test:playwright +npx playwright test e2e-tests/specs/some-spec.spec.ts # Single spec + +# Bundle size check +yarn run size +``` + +## Architecture + +### PHP (Backend) + +Two PSR-4 autoloaded namespaces: +- **`Neve\*`** → `inc/` — Core theme: customizer, views, compatibility layers, admin +- **`HFG\*`** → `header-footer-grid/` — Header/footer drag-and-drop builder module + +Key entry points: +- `functions.php` — Bootstrap, constants (`NEVE_VERSION`, `NEVE_INC_DIR`, etc.) +- `inc/core/core_loader.php` — Main feature factory, loads all theme features +- `header-footer-grid/Main.php` — HFG module entry point + +`inc/compatibility/` contains integrations for 20+ plugins (WooCommerce, Elementor, Beaver Builder, WPML, etc.). + +### JavaScript/React (Frontend & Admin) + +Three separate build systems: +- **Rollup** → `assets/js/src/` → `assets/js/build/{modern,all}/` (frontend vanilla JS with modern/legacy bundles) +- **Webpack** (`@wordpress/scripts`) → `assets/apps/*/src/` → `assets/apps/*/build/` (React admin apps) +- **Grunt** → `assets/scss/` → compiled CSS + RTL variants + +React apps in `assets/apps/`: +- `components/` — Shared component library published as `@neve-wp/components` (local file dependency) +- `dashboard/` — Theme settings dashboard +- `customizer-controls/` — WordPress Customizer React UI +- `metabox/` — Post/page metabox editor +- `starter-sites/` — Demo content installer notice + +### CSS + +SCSS source in `assets/scss/`, compiled via Grunt. Tailwind CSS is used in some apps. RTL stylesheets are auto-generated. + +## Sub-Folder Lookup Map + +Use this as a fast entry point before deeper grep/search. + +| Path | What lives here | Start here when... | +|---|---|---| +| `inc/` | Core theme PHP (`Neve\*`): feature bootstrapping, customizer options, admin, plugin compat, render helpers | You need to change theme behavior in PHP | +| `inc/core/` | Main runtime wiring (feature loading, settings, styles, shared traits) | You are tracing initialization or global feature toggles | +| `inc/customizer/` | Customizer option definitions, defaults, control types, traits | You are adding/changing Customizer settings | +| `inc/views/` | PHP view layer (layouts, partials, inline output, pluggable pieces) | You are editing rendered markup/output structure | +| `inc/compatibility/` | Integrations with plugins (WooCommerce, Elementor, WPML, etc.) | A bug appears only when a plugin is active | +| `header-footer-grid/` | Header/Footer builder module (`HFG\*`): builder components, customizer integration, templates, assets | Work is specific to header/footer builder behavior | +| `header-footer-grid/Core/Components/` | Individual HFG components and utilities | You are modifying one header/footer element | +| `assets/apps/components/` | Shared React UI package (`@neve-wp/components`) consumed by other apps | Multiple apps need the same UI/control update | +| `assets/apps/dashboard/` | Dashboard React app | Theme dashboard/admin UX changes | +| `assets/apps/customizer-controls/` | React controls rendered in WordPress Customizer | Customizer-side React UI/control logic changes | +| `assets/apps/metabox/` | Post/page metabox React app | Metabox UI or save behavior changes | +| `assets/apps/starter-sites/` | Starter-sites notice/installer app | Demo/starter-site flows need updates | +| `assets/js/src/` | Frontend vanilla JS sources (Rollup modern + legacy bundles) | Runtime frontend interactions outside React apps | +| `assets/scss/` | Global/theme SCSS source split by components/elements/compat | Styling changes in frontend theme CSS | +| `assets/customizer/` | Legacy/customizer-specific static CSS/JS | Issue is in older non-React customizer assets | +| `template-parts/` | Reusable template chunks used by core theme templates | You need to adjust a reusable template fragment | +| `views/` | Additional PHP view templates used by theme rendering | You are tracking frontend HTML generation | +| `page-templates/` | Assignable WordPress page templates | You are changing template-level page layouts | +| `woocommerce/` | WooCommerce template overrides | WooCommerce-only frontend markup/styles need changes | +| `tests/` | PHPUnit tests and PHP test helpers | You are adding or updating PHP unit test coverage | +| `tests/php/` | PHP static-analysis stubs (e.g. for Psalm/PHPStan) | You need or are adjusting PHP static-analysis support | +| `e2e-tests/specs/` | Playwright end-to-end specs | Validating editor/admin/frontend behavior end-to-end | +| `stories/` | Storybook stories and local UI playground assets | You need isolated component UI verification | +| `docs/` | Project docs and contributor references | You need implementation conventions or team guidance | +| `grunt/` + `Gruntfile.js` | CSS/asset build pipeline tasks | SCSS output/build-step behavior is wrong | +| `rollup.config.js` | Frontend JS bundling config for `assets/js/src/` | Entry points/output/chunk behavior must change | +| `webpack.config.js` | React app bundling config (`assets/apps/*`) | Build behavior for dashboard/customizer/metabox apps | diff --git a/assets/scss/components/compat/woocommerce/_buttons.scss b/assets/scss/components/compat/woocommerce/_buttons.scss index c544e387d9..e1023194d6 100644 --- a/assets/scss/components/compat/woocommerce/_buttons.scss +++ b/assets/scss/components/compat/woocommerce/_buttons.scss @@ -53,6 +53,7 @@ a.added_to_cart, justify-content: center; display: inline-flex !important; padding-right: 15px !important; + align-items: center; &::after { margin-left: 5px; diff --git a/assets/scss/gutenberg-editor-style.scss b/assets/scss/gutenberg-editor-style.scss index 8e72e9d408..ff3df8e65d 100644 --- a/assets/scss/gutenberg-editor-style.scss +++ b/assets/scss/gutenberg-editor-style.scss @@ -5,7 +5,7 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - &, > * { + &, > *:not(.block-canvas-cover) { background-color: var(--nv-site-bg); color: var(--nv-text-color); } diff --git a/inc/compatibility/woocommerce.php b/inc/compatibility/woocommerce.php index c0c7f578cc..480fdcc6d9 100644 --- a/inc/compatibility/woocommerce.php +++ b/inc/compatibility/woocommerce.php @@ -907,7 +907,7 @@ private function move_checkout_coupon() { * Move the coupon field and message info after the order table. */ public function move_coupon() { - wc_enqueue_js( '$( $( ".woocommerce-checkout div.woocommerce-info, .checkout_coupon, .woocommerce-form-login" ).detach() ).appendTo( "#neve-checkout-coupon" );' ); + wp_add_inline_script( 'neve-script', 'jQuery( jQuery( ".woocommerce-checkout div.woocommerce-info, .checkout_coupon, .woocommerce-form-login" ).detach() ).appendTo( "#neve-checkout-coupon" );' ); } /** diff --git a/inc/views/partials/excerpt.php b/inc/views/partials/excerpt.php index 4d638e4f29..8ed1ea284b 100644 --- a/inc/views/partials/excerpt.php +++ b/inc/views/partials/excerpt.php @@ -48,7 +48,7 @@ private function get_post_excerpt( $context, $post_id = null ) { $length = $this->get_excerpt_length(); $output = ''; - $output .= '
'; + $output .= '
'; $output .= $this->get_excerpt( $length, $post_id ); $output .= '
'; diff --git a/inc/views/pluggable/pagination.php b/inc/views/pluggable/pagination.php index a0e95f9cf5..d75bc8d270 100644 --- a/inc/views/pluggable/pagination.php +++ b/inc/views/pluggable/pagination.php @@ -55,13 +55,14 @@ public function get_posts( \WP_REST_Request $request ) { return new \WP_REST_Response( '' ); } - $query_args = $request->get_body(); - $args = json_decode( $query_args, true ); - - $per_page = get_option( 'posts_per_page' ); + $page_number = absint( $request['page_number'] ); + $query_args = $request->get_body(); + $args = json_decode( $query_args, true ); + $per_page = get_option( 'posts_per_page' ); if ( $per_page > 100 ) { $per_page = 100; } + $args = $this->sanitize_infinite_scroll_query_args( is_array( $args ) ? $args : array() ); /** * If homepage is set to 'A static page', there will be a parameter inside the query named 'pagename'. @@ -73,24 +74,17 @@ public function get_posts( \WP_REST_Request $request ) { } $args['posts_per_page'] = $per_page; - - if ( empty( $args['post_type'] ) ) { - $args['post_type'] = 'post'; - } - - $args['paged'] = $request['page_number']; - $args['ignore_sticky_posts'] = 1; - $args['post_status'] = 'publish'; + $args['paged'] = $page_number; if ( ! empty( $request['lang'] ) ) { if ( defined( 'POLYLANG_VERSION' ) ) { - $args['lang'] = $request['lang']; + $args['lang'] = sanitize_text_field( $request['lang'] ); } if ( defined( 'ICL_SITEPRESS_VERSION' ) ) { global $sitepress; if ( gettype( $sitepress ) === 'object' && method_exists( $sitepress, 'switch_lang' ) ) { - $sitepress->switch_lang( $request['lang'] ); + $sitepress->switch_lang( sanitize_text_field( $request['lang'] ) ); } } } @@ -302,6 +296,109 @@ public function render_post_navigation() { echo '
'; } + /** + * Sanitize query arguments for infinite scroll to prevent query manipulation. + * + * This method implements a strict allowlist approach to prevent: + * - Expensive database queries (DoS risk via meta_query, tax_query, etc.) + * - Exposure of unintended content types + * - Manipulation of query parameters by anonymous users + * + * @param array $args Raw query arguments from client request. + * + * @return array Sanitized query arguments safe for WP_Query. + */ + private function sanitize_infinite_scroll_query_args( $args ) { + // Define allowlist of safe query parameters for public infinite scroll. + $allowed_keys = array( + 'category_name', + 'tag', + 's', + 'order', + 'orderby', + 'author', + 'author_name', + 'year', + 'monthnum', + 'day', + ); + + $sanitized = array(); + foreach ( $allowed_keys as $key ) { + if ( isset( $args[ $key ] ) ) { + $sanitized[ $key ] = $args[ $key ]; + } + } + + if ( isset( $sanitized['category_name'] ) ) { + $sanitized['category_name'] = sanitize_text_field( $sanitized['category_name'] ); + } + if ( isset( $sanitized['tag'] ) ) { + $sanitized['tag'] = sanitize_text_field( $sanitized['tag'] ); + } + if ( isset( $sanitized['s'] ) ) { + $sanitized['s'] = sanitize_text_field( $sanitized['s'] ); + } + if ( isset( $sanitized['order'] ) ) { + $order_upper = is_string( $sanitized['order'] ) ? strtoupper( $sanitized['order'] ) : ''; + $sanitized['order'] = in_array( $order_upper, array( 'ASC', 'DESC' ), true ) ? $order_upper : 'DESC'; + } + if ( isset( $sanitized['orderby'] ) ) { + $safe_orderby = array( 'date', 'title', 'author', 'modified', 'comment_count' ); + $sanitized['orderby'] = in_array( $sanitized['orderby'], $safe_orderby, true ) ? $sanitized['orderby'] : 'date'; + } + if ( isset( $sanitized['author'] ) ) { + $sanitized['author'] = absint( $sanitized['author'] ); + } + if ( isset( $sanitized['author_name'] ) ) { + $sanitized['author_name'] = sanitize_user( $sanitized['author_name'] ); + } + if ( isset( $sanitized['year'] ) ) { + $sanitized['year'] = absint( $sanitized['year'] ); + } + if ( isset( $sanitized['monthnum'] ) ) { + $sanitized['monthnum'] = absint( $sanitized['monthnum'] ); + } + if ( isset( $sanitized['day'] ) ) { + $sanitized['day'] = absint( $sanitized['day'] ); + } + + $post_type = ( ! empty( $args['post_type'] ) && is_string( $args['post_type'] ) ) ? sanitize_key( $args['post_type'] ) : 'post'; + $post_type_obj = get_post_type_object( $post_type ); + + // Only allow if post type exists and is publicly queryable. + if ( $post_type_obj && $post_type_obj->publicly_queryable ) { + $sanitized['post_type'] = $post_type; + } else { + $sanitized['post_type'] = 'post'; + } + + // Explicitly unset dangerous query args that could be smuggled in. + $dangerous_keys = array_flip( + array( + 'meta_query', + 'meta_key', + 'meta_value', + 'meta_value_num', + 'meta_compare', + 'tax_query', + 'fields', + 'post__in', + 'post__not_in', + 'post_parent', + 'post_parent__in', + 'post_parent__not_in', + ) + ); + $sanitized = array_diff_key( $sanitized, $dangerous_keys ); + + // Force safe defaults for core query behavior. + $sanitized['post_status'] = 'publish'; + $sanitized['ignore_sticky_posts'] = 1; + + return $sanitized; + } + /** * Go to page option is enabled * diff --git a/inc/views/template_parts.php b/inc/views/template_parts.php index e465726bd9..343ca85967 100644 --- a/inc/views/template_parts.php +++ b/inc/views/template_parts.php @@ -473,7 +473,7 @@ public function link_excerpt_more( $moretag, $post_id = null ) { ); // Return $new_moretag if 'text' key is not set in $read_more_args. - if ( ! isset( $read_more_args['text'] ) ) { + if ( ! isset( $read_more_args['text'] ) || empty( $read_more_args['text'] ) ) { return $new_moretag; }