diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..fe8a69b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,29 @@ +name: Deploy to SiteGround +on: + push: + branches: [stg, main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + deploy-prod: + name: Deploy to Production + if: github.ref == 'refs/heads/main' + uses: macrosbysara/shared-github-actions/.github/workflows/deploy-theme.yml@main + with: + REMOTE: "macrosbysara.com" # Remote folder to deploy to + theme_name: "macros-by-sara" + flags: "-azr --delete" + secrets: inherit + + deploy-stg: + name: Deploy to Staging + if: github.ref == 'refs/heads/stg' + uses: macrosbysara/shared-github-actions/.github/workflows/deploy-theme.yml@main + with: + REMOTE: staging6.macrosbysara.com + theme_name: "macros-by-sara" + flags: "-azvr --delete" + secrets: inherit diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..986ffb4 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,49 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Listen for Xdebug 3.0 (Local)", + "type": "php", + "request": "launch", + "port": 9003, + "xdebugSettings": { + "max_children": 128, + "max_data": 1024, + "max_depth": 3, + "show_hidden": 1 + }, + "pathMappings": { + "/Users/kjroelke/Local Sites/macros-by-sara/app/public/wp-content/themes/macros-by-sara": "${workspaceFolder}" + } + }, + { + "name": "Listen for Xdebug (Local)", + "type": "php", + "request": "launch", + "port": 9000, + "xdebugSettings": { + "max_children": 128, + "max_data": 1024, + "max_depth": 3, + "show_hidden": 1 + }, + "pathMappings": { + "/Users/kjroelke/Local Sites/macros-by-sara/app/public/wp-content/themes/macros-by-sara": "${workspaceFolder}" + } + }, + { + "name": "Launch currently open script", + "type": "php", + "request": "launch", + "program": "${file}", + "cwd": "${fileDirname}", + "port": 9000, + "xdebugSettings": { + "max_children": 128, + "max_data": 1024, + "max_depth": 3, + "show_hidden": 1 + } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index a3145ce..15f9023 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,7 +35,21 @@ "editor.useTabStops": true, "prettier.useTabs": false }, - "cSpell.words": [ "cpts", "Linktree", "macrosbysara", "wpcf" ], + "cSpell.words": [ + "alignfull", + "alignwide", + "atts", + "azvr", + "bloginfo", + "choctawnationofoklahoma", + "cpts", + "Linktree", + "macrosbysara", + "remoteip", + "trackbacks", + "wght", + "wpcf" + ], "css.format.spaceAroundSelectorSeparator": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "always", diff --git a/changelog.md b/changelog.md index a786783..1a97ce2 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,9 @@ # Changelog +## 2.0.0 - [November 1, 2025] + +- New Theme! + ## 1.3.2 - [October 29, 2025] - Chore: Add lints and configs diff --git a/eslint.config.mjs b/eslint.config.mjs index 95ed3cc..691448f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,10 +1,7 @@ import globals from 'globals'; import { fixupConfigRules, includeIgnoreFile } from '@eslint/compat'; import wordpressConfig from '@wordpress/eslint-plugin'; - -// eslint-disable-next-line import/no-unresolved import { globalIgnores, defineConfig } from 'eslint/config'; - import { FlatCompat } from '@eslint/eslintrc'; import path from 'path'; import { fileURLToPath, URL } from 'url'; @@ -29,7 +26,7 @@ export default defineConfig( [ ) ), { - files: [ 'wp-content/themes/**/src/js/**/*.{js,ts,jsx,tsx}' ], + files: [ 'src/js/**/*.{js,ts,jsx,tsx}' ], languageOptions: { globals: globals.browser, }, diff --git a/inc/theme/class-gutenberg-handler.php b/inc/theme/class-gutenberg-handler.php index ace6708..f75503c 100644 --- a/inc/theme/class-gutenberg-handler.php +++ b/inc/theme/class-gutenberg-handler.php @@ -18,7 +18,39 @@ class Gutenberg_Handler { * Constructor */ public function __construct() { + add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_assets' ) ); add_action( 'after_setup_theme', array( $this, 'theme_supports' ) ); + add_action( 'init', array( $this, 'register_theme_blocks' ) ); + } + + /** + * Enqueue the block editor assets that control the layout of the Block Editor. + */ + public function enqueue_block_assets() { + $files = array( + 'editDefaultBlocks' => 'script', + 'editor' => 'style', + ); + foreach ( $files as $handle => $type ) { + $assets = require_once get_stylesheet_directory() . "/build/admin/{$handle}.asset.php"; + if ( 'style' === $type || 'both' === $type ) { + wp_enqueue_style( + $handle, + get_stylesheet_directory_uri() . "/build/admin/{$handle}.css", + $assets['dependencies'], + $assets['version'] + ); + } + if ( 'script' === $type || 'both' === $type ) { + wp_enqueue_script( + $handle, + get_stylesheet_directory_uri() . "/build/admin/{$handle}.js", + $assets['dependencies'], + $assets['version'], + array( 'strategy' => 'defer' ) + ); + } + } } /** @@ -26,14 +58,26 @@ public function __construct() { */ public function theme_supports() { $opt_in_features = array( - 'disable-custom-colors', 'responsive-embeds', - 'disable-custom-gradients', - 'disable-custom-font-sizes', ); - foreach ( $opt_in_features as $feature ) { add_theme_support( $feature ); } + + $opt_out_features = array( + 'core-block-patterns', + ); + foreach ( $opt_out_features as $feature ) { + remove_theme_support( $feature ); + } + } + + /** + * Register any theme-specific blocks + */ + public function register_theme_blocks() { + // Load blocks + $blocks_path = get_template_directory() . '/build'; + wp_register_block_types_from_metadata_collection( $blocks_path . '/js/blocks', $blocks_path . '/blocks-manifest.php' ); } } diff --git a/inc/theme/class-rest-router.php b/inc/theme/class-rest-router.php new file mode 100644 index 0000000..bcf10e0 --- /dev/null +++ b/inc/theme/class-rest-router.php @@ -0,0 +1,182 @@ +namespace = 'mbs/v1'; + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_interest_form_script' ), 100 ); + } + + /** + * Register routes. + */ + public function register_routes() { + register_rest_route( + $this->namespace . '/forms', + '/interest-form', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'handle_interest_form' ), + 'permission_callback' => array( $this, 'allow_public_access' ), + 'args' => array( + 'firstName' => array( + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'lastName' => array( + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'email' => array( + 'required' => true, + 'sanitize_callback' => 'sanitize_email', + 'validate_callback' => 'is_email', + ), + 'interest' => array( + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ( $param ) { + $valid_options = array( + 'macros', + 'habits', + 'one-time-macros', + 'fitness', + ); + return in_array( $param, $valid_options, true ); + }, + ), + ), + ) + ); + } + + /** + * Example endpoint callback. + * + * @param WP_REST_Request $request The REST request. + * @return WP_REST_Response The REST response. + */ + public function handle_interest_form( WP_REST_Request $request ): WP_REST_Response { + $first_name = $request->get_param( 'firstName' ); + $last_name = $request->get_param( 'lastName' ); + $email = $request->get_param( 'email' ); + $interest = $request->get_param( 'interest' ); + $data = array( + 'code' => 'success', + 'message' => 'Interest form submitted successfully!', + 'data' => array( + 'status' => 200, + 'firstName' => $first_name, + 'lastName' => $last_name, + 'email' => $email, + 'interest' => $interest, + ), + ); + return rest_ensure_response( $data ); + } + + /** + * Enqueue interest form script with localized REST API data. + */ + public function enqueue_interest_form_script() { + wp_localize_script( + 'global', + 'mbsRestApi', + array( + 'root' => esc_url_raw( rest_url() . $this->namespace ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ) + ); + wp_enqueue_script( + 'cloudflare', + 'https://challenges.cloudflare.com/turnstile/v0/api.js', + array( 'global' ), + null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion + array( + 'strategy' => 'async', + ) + ); + } + + /** + * Allow public access to the endpoint. + * + * @param WP_REST_Request $request The REST request. + * @return bool + */ + public function allow_public_access( WP_REST_Request $request ): bool { + if ( ! defined( 'CF_TURNSTILE_SECRET' ) || empty( CF_TURNSTILE_SECRET ) ) { + return false; + } + $nonce = null; + $headers = $request->get_headers(); + if ( isset( $headers['x_wp_nonce'] ) ) { + $nonce = $headers['x_wp_nonce']; + } + $verified = wp_verify_nonce( $nonce[0], 'wp_rest' ); + if ( ! $verified ) { + return false; + } + if ( ! isset( $_POST['cf-turnstile-response'] ) ) { + return false; + } + return $this->cloudflare_validation( + sanitize_text_field( $_POST['cf-turnstile-response'] ?? '' ) + ); + } + + + /** + * Validate Cloudflare Turnstile response. + * + * @param string $token The Turnstile token from the client. + * @param string|null $remoteip Optional. The user's IP address. + * @return bool True if validation is successful, false otherwise. + */ + private function cloudflare_validation( string $token, ?string $remoteip = null ): bool { + $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + + $data = array( + 'secret' => CF_TURNSTILE_SECRET, + 'response' => $token, + ); + + if ( $remoteip ) { + $data['remoteip'] = $remoteip; + } + + $response = wp_remote_post( + $url, + array( + 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded' ), + 'body' => $data, + 'timeout' => 10, + ) + ); + if ( is_wp_error( $response ) ) { + return false; + } + + $response = wp_remote_retrieve_body( $response ); + $response_data = json_decode( $response, true ); + return $response_data['success'] ?? false; + } +} diff --git a/inc/theme/class-theme-init.php b/inc/theme/class-theme-init.php index 165493a..951cf4b 100644 --- a/inc/theme/class-theme-init.php +++ b/inc/theme/class-theme-init.php @@ -13,68 +13,110 @@ * Class: Theme Init */ class Theme_Init { + /** + * Utils Loader + * + * @var Utils_Loader $loader + */ + private Utils_Loader $loader; /** * Constructor */ public function __construct() { - $this->load_files(); - add_filter( 'x_enqueue_parent_stylesheet', '__return_true' ); + require_once get_stylesheet_directory() . '/inc/theme/class-utils-loader.php'; + $this->loader = new Utils_Loader(); + $this->loader->load_files(); + $this->disable_discussion(); + add_action( 'after_setup_theme', array( $this, 'configure_theme_support' ) ); + add_action( 'init', array( $this, 'alter_post_types' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); add_filter( 'wp_speculation_rules_configuration', array( $this, 'handle_speculative_loading' ) ); } - /** - * Load Required Files - */ - private function load_files() { - $base_path = get_stylesheet_directory() . '/inc'; - $theme_files = array( - 'gutenberg-handler' => 'Gutenberg_Handler', - ); - foreach ( $theme_files as $file => $class ) { - require_once $base_path . "/theme/class-{$file}.php"; - if ( $class ) { - $class = __NAMESPACE__ . "\\{$class}"; - new $class(); + /** Remove comments, pings and trackbacks support from posts types. */ + private function disable_discussion() { + // Close comments on the front-end + add_filter( 'comments_open', '__return_false', 20, 2 ); + add_filter( 'pings_open', '__return_false', 20, 2 ); + + // Hide existing comments. + add_filter( 'comments_array', '__return_empty_array', 10, 2 ); + // Remove comments page in menu. + add_action( + 'admin_menu', + function () { + remove_menu_page( 'edit-comments.php' ); } - } - $plugin_files = array( - 'acf-handler' => array( - 'class' => 'ACF_Handler', - 'dir' => 'acf', - ), ); - foreach ( $plugin_files as $file => $data ) { - require_once $base_path . "/plugins/{$data['dir']}/class-{$file}.php"; - if ( $data['class'] ) { - $class = __NAMESPACE__ . "\\{$data['class']}"; - new $class(); + // Remove comments links from admin bar. + add_action( + 'init', + function () { + if ( is_admin_bar_showing() ) { + remove_action( 'admin_bar_menu', 'wp_admin_bar_comments_menu', 60 ); + } } - } + ); } + /** Registers Theme Supports */ + public function configure_theme_support() { + add_theme_support( 'post-thumbnails' ); + add_theme_support( 'title-tag' ); + + register_nav_menus( + array( + 'primary_menu' => 'Primary Menu', + 'footer_menu' => 'Footer Menu', + ) + ); + } + + /** Alter Post Types. */ + public function alter_post_types() { + add_post_type_support( 'page', 'excerpt' ); + } + + /** * Enqueue scripts and styles. */ public function enqueue_scripts(): void { - $global_assets = require_once get_stylesheet_directory() . '/build/index.asset.php'; - - wp_enqueue_script( - 'global', - get_stylesheet_directory_uri() . '/build/index.js', - $global_assets['dependencies'], - $global_assets['version'], - array( 'strategy' => 'defer' ) - ); - wp_enqueue_style( - 'global', - get_stylesheet_directory_uri() . '/build/index.css', - $global_assets['dependencies'], - $global_assets['version'], + $files = array( + 'bootstrap' => array( + 'js' => 'vendors/bootstrap', + 'css' => 'vendors/bootstrap', + ), + 'global' => array( + 'js' => 'global', + 'css' => 'global', + ), ); + foreach ( $files as $handle => $paths ) { + $assets = require_once get_stylesheet_directory() . "/build/{$paths['js']}.asset.php"; + + $deps = $assets['dependencies']; + if ( 'bootstrap' !== $handle ) { + // Ensure assets load after bootstrap for proper overrides + $deps = array_merge( $deps, array( 'bootstrap' ) ); + } + wp_enqueue_script( + $handle, + get_stylesheet_directory_uri() . "/build/{$paths['js']}.js", + $deps, + $assets['version'], + array( 'strategy' => 'defer' ) + ); + wp_enqueue_style( + $handle, + get_stylesheet_directory_uri() . "/build/{$paths['css']}.css", + $deps, + $assets['version'], + ); + } } /** diff --git a/inc/theme/class-utils-loader.php b/inc/theme/class-utils-loader.php new file mode 100644 index 0000000..d8d3628 --- /dev/null +++ b/inc/theme/class-utils-loader.php @@ -0,0 +1,80 @@ +base_path = get_stylesheet_directory() . '/inc'; + } + + /** + * Load Required Files + */ + public function load_files() { + + // Require Navwalker + require_once $this->base_path . '/theme/navwalkers/class-navwalker.php'; + + // Theme Utils + $theme_files = array( + 'gutenberg-handler' => 'Gutenberg_Handler', + 'rest-router' => 'Rest_Router', + ); + $this->load_utils( '/theme', $theme_files ); + $this->load_acf_utils(); + } + + /** + * Load Utils + * + * @param string $path Path to utils. + * @param array $files Array of files to load. + */ + private function load_utils( string $path, array $files ) { + foreach ( $files as $file => $class ) { + require_once $this->base_path . "{$path}/class-{$file}.php"; + if ( $class ) { + $class = __NAMESPACE__ . "\\{$class}"; + new $class(); + } + } + } + + /** + * Load ACF Utils + */ + private function load_acf_utils() { + $plugin_files = array( + 'acf-handler' => array( + 'class' => 'ACF_Handler', + 'dir' => 'acf', + ), + ); + foreach ( $plugin_files as $file => $data ) { + require_once $this->base_path . "/plugins/{$data['dir']}/class-{$file}.php"; + if ( $data['class'] ) { + $class = __NAMESPACE__ . "\\{$data['class']}"; + new $class(); + + } + } + } +} diff --git a/inc/theme/navwalkers/class-navwalker.php b/inc/theme/navwalkers/class-navwalker.php new file mode 100644 index 0000000..1809e3c --- /dev/null +++ b/inc/theme/navwalkers/class-navwalker.php @@ -0,0 +1,245 @@ + + * + * // end_lvl() + * + * @package MacrosBySara + */ + +namespace MacrosBySara; + +use stdClass; +use Walker_Nav_Menu; +use WP_Post; + +/** + * Extends the WP Nav Walker + * Based on the CNO Navwalker + * + * @link https://github.com/choctaw-nation/cno-template-theme/blob/hybrid-block-theme/wp-content/themes/cno-starter-theme/inc/theme/navwalkers/class-navwalker.php + */ +class Navwalker extends Walker_Nav_Menu { + /** The current nav item + * + * @var WP_Post $current_item + */ + protected WP_Post $current_item; + + /** + * Bootstrap alignment classes + * + * @var string[] $dropdown_menu_alignment_values + */ + protected $dropdown_menu_alignment_values = array( + 'dropdown-menu-start', + 'dropdown-menu-end', + 'dropdown-menu-sm-start', + 'dropdown-menu-sm-end', + 'dropdown-menu-md-start', + 'dropdown-menu-md-end', + 'dropdown-menu-lg-start', + 'dropdown-menu-lg-end', + 'dropdown-menu-xl-start', + 'dropdown-menu-xl-end', + 'dropdown-menu-xxl-start', + 'dropdown-menu-xxl-end', + ); + + /** + * Depth of menu item. Used for padding. + * + * @var int $depth + */ + protected int $depth; + + /** + * The array of wp_nav_menu() arguments as an object. + * + * @var ?stdClass $args + */ + protected ?stdClass $args; + + /** + * Optional. ID of the current menu item. Default 0. + * + * @var int $id + */ + protected int $id; + + /** Whether Current Item in Navwalker is Top Level or not + * + * @var bool $is_top_level + */ + protected bool $is_top_level; + + /** + * Whether the current item has an href pointing to '#' or not + * + * @var bool $href_is_empty + */ + protected bool $href_is_empty; + + /** + * The Opening Level + * + * @param string $output the html + * @param int $depth whether we are at the top-level or a sub-level + * @param ?stdClass $args An object of wp_nav_menu() arguments. + */ + public function start_lvl( &$output, $depth = 0, $args = \null ) { + $dropdown_menu_class = array(); + // handle user-inputted classes + foreach ( $this->current_item->classes as $class ) { + if ( in_array( $class, $this->dropdown_menu_alignment_values, true ) ) { + $dropdown_menu_class[] = $class; + } + } + $indent = str_repeat( "\t", $depth ); + $this->is_top_level = 0 === $depth; + $submenu = ( $this->is_top_level ) ? '' : ' sub-menu'; + + $output .= "\n$indent