diff --git a/.stylelintrc.json b/.stylelintrc.json index c94dbbd..17af94e 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -9,7 +9,6 @@ "assets/css/fontawesome6/**", "assets/css/flexslider.css", "assets/js/**", - "inc/libraries/**", "layouts/**" ], "rules": { diff --git a/CLAUDE.md b/CLAUDE.md index 838b9a8..0c0e2a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,12 +56,12 @@ The version lives in **four** places, all currently in sync at 1.2.20: `style.cs Nearly all theme behavior is driven by `get_theme_mod()` reads scattered across templates. The chain: 1. `inc/customizer.php` (~1500 lines) registers the `shapely_main_options` panel and its sections/controls. -2. `inc/libraries/epsilon-framework/` — a **vendored** third-party customizer framework (Macho Themes) supplying `Epsilon_Control_Toggle`, `Epsilon_Control_Slider`, `Epsilon_Section_Pro`, `Epsilon_Section_Recommended_Actions`. `Shapely::init_epsilon()` passes a whitelist of controls/sections to load. Classes resolve through `class-epsilon-autoloader.php`, which maps `Foo_Bar` → `class-foo-bar.php` across a fixed directory list. +2. `inc/custom-controls/` — the theme's own customizer controls and sections: `Shapely_Custom_Label`, `Shapely_Logo_Dimensions`, `Shapely_Control_Range` (a range input with a value readout), and `Shapely_Section_Link` (a section rendered as a single outbound button). Everything else uses core control types. The vendored Epsilon framework that used to supply these was removed in 1.3.0. 3. `shapely_get_theme_options()` in `inc/extras.php` builds a CSS string from those theme mods. `shapely_enqueue_theme_options_css()` buffers it and hands it to `wp_add_inline_style( 'shapely-style', … )`, so it rides along with the main stylesheet rather than being echoed straight into `wp_head`. So **adding a color/typography option means two edits**: a control in `inc/customizer.php` *and* a selector block in `shapely_get_theme_options()`. Sanitizers (`shapely_sanitize_checkbox`, `shapely_sanitize_layout`, …) live at the bottom of `customizer.php`. -The Epsilon JS bundles (`assets/js/epsilon-framework-*.js`) are webpack output from TypeScript/Vue sources in `inc/libraries/epsilon-framework/assets/vendors/` — do not hand-edit the bundles; rebuild with `npm run build` **inside `inc/libraries/epsilon-framework/`** (its own webpack setup, unrelated to the theme's `npm run build`). +`theme.json` (v2, matching the WordPress 6.4 floor) is the source of truth for the palette, type scale, heading sizes and layout widths — it is what the block editor reads. The customizer is still where a site owner changes colours: `shapely_enqueue_theme_options_css()` emits each colour theme mod as an override of the matching `--wp--preset--color--*` property, so blocks and the classic front end resolve to one value rather than two palettes disagreeing. **Adding a colour option now means three edits**: a control in `inc/customizer.php`, a selector block in `shapely_get_theme_options()`, and a preset in `theme.json` plus its entry in the `$presets` map. ### Layout resolution @@ -80,7 +80,7 @@ Blog listings (`index.php`, `archive.php`) dispatch on the `blog_layout_view` th - **WooCommerce**: theme support in `functions.php`, `woocommerce.php` as the wrapper, `woocommerce/product-searchform.php` as the only template override. The `shop-sidebar` widget area registers only when `shapely_is_woocommerce_activated()`. `woocommerce/` is excluded from phpcs. - **Jetpack**: `inc/jetpack.php` plus `archive-jetpack-portfolio.php` / `single-jetpack-portfolio.php` for the portfolio CPT (masonry is enqueued only when that CPT exists). -- **Welcome screen / recommended plugins**: `inc/libraries/welcome-screen/` + `inc/class-shapely-notify-system.php` drive the "Import Demo Content" flow and the recommended-plugins list defined in `Shapely::$recommended_plugins`. +- **Welcome screen / recommended plugins**: `inc/admin/class-shapely-welcome.php` + `inc/class-shapely-notify-system.php` drive the "Import Demo Content" flow and the recommended-plugins list defined in `Shapely::$recommended_plugins`. The import itself is **not** in the theme: the button posts `shapely_companion_import_content` with a `welcome_nonce` to a handler in the shapely-companion plugin, so that action name, its `import` values and the nonce name are a fixed contract — changing any of them breaks the import against every released version of the plugin. Plugin installation is handed to core's `wp.updates`. ### Frontend JS @@ -96,14 +96,14 @@ Every optional third-party plugin (FlexSlider, OwlCarousel, imagesLoaded, YTPlay - Font Awesome 6 splits families: brand glyphs (`fa-github`, `fa-x-twitter`, …) need `fa-brands`, everything else uses `fa`/`fa-solid`. A brand icon rendered with plain `fa` shows a blank box. FA4 `-o` outline suffixes no longer exist. The bundled build is 6.4.2, so icons added later (e.g. `fa-bluesky`, 6.6) are unavailable. - PHP follows WordPress-Core via `phpcs.ruleset.xml` (plus `PHPCompatibility`; `node_modules/` and `woocommerce/` excluded). Newer files open with an `ABSPATH` guard. - JS follows `.jshintrc`: `es3`, single quotes, mandatory curly braces, `eqeqeq`. New localized objects must be added to its `globals` whitelist or jshint fails. -- Text domain is `shapely` (`epsilon-framework` is also allowed by checktextdomain). Translations live in `languages/`. +- Text domain is `shapely`, and it is the only one allowed by `npm run i18n:check`. Translations live in `languages/`. - Supported floor: WordPress 6.4 / PHP 7.4, tested to WordPress 6.8 / PHP 8.4 (stated in `style.css`, `readme.txt`, and `functions.php`). ## Repo quirks worth knowing -- `README.md` documents a git-submodule workflow and `setup.sh` / `setup.bat` — **none of that exists**: `.gitmodules` is empty, no submodules are registered, and the Epsilon framework is vendored directly in `inc/libraries/`. +- `README.md` documents a git-submodule workflow and `setup.sh` / `setup.bat` — **none of that exists**: `.gitmodules` is empty and no submodules are registered. - `.gitignore` used to list `Gruntfile.js`, `.jshintrc`, `.travis.yml`, `phpcs.ruleset.xml` and `package-lock.json`. The first three are gone; the last two are now deliberately tracked (`npm ci` needs the lockfile, and the phpcs ruleset is real config). - The zip builder in `tools/build-zip.mjs` is deny-by-default: a file ships unless `EXCLUDE` matches it. The old Grunt copy task was allow-everything-then-subtract, which is how `CLAUDE.md` and a nested `package.json` ended up inside released zips. -- The vendored `inc/libraries/` code is third-party but **not** a submodule, so it is edited in place when it blocks a PHP/WordPress upgrade. Two such patches exist: optional-before-required parameters in `class-epsilon-control-section-repeater.php`, and `get_page_by_title()` (deprecated in WP 6.2) in `class-epsilon-welcome-screen.php`. +- `inc/class-shapely-migrations.php` runs one-time upgrade steps, keyed on the `shapely_migrated_version` option. No theme mod has ever been renamed, so it does not map settings — it carries the state the retired Epsilon framework owned (`shapely_actions_left` → `shapely_dismissed_actions`) and suppresses the onboarding notice on sites that were already established. - `Shapely_Notify_System` overrides the vendored `check_plugin_is_installed()`/`check_plugin_is_active()` because the parent hardcodes `ABSPATH . 'wp-content/plugins/'` and breaks on relocated content directories. - `layouts/content-sidebar.css` and `layouts/sidebar-content.css` are leftovers from Underscores and are not enqueued anywhere. diff --git a/assets/css/editor-style.css b/assets/css/editor-style.css index 4c6ad50..e75cc02 100644 --- a/assets/css/editor-style.css +++ b/assets/css/editor-style.css @@ -1,76 +1,39 @@ /* * Block editor styles. * - * Mirrors the front-end typography from style.css so that what is written in - * the editor resembles what the visitor sees. Values are copied from the - * corresponding front-end rules rather than reinvented -- when style.css - * changes, this file needs the same change. + * Deliberately short. theme.json now carries the palette, the type scale, the + * heading sizes and the link and button treatments, and WordPress applies those + * to the editor canvas on its own -- so anything expressible there has been + * removed from here rather than maintained in two places that can drift. * - * Scoped by the editor itself: WordPress rewrites these selectors to apply - * inside the editor canvas only, so plain element selectors are correct here - * and must not be prefixed by hand. + * What is left is the handful of front-end rules from style.css that theme.json + * has no vocabulary for. */ -.editor-styles-wrapper { - font-family: Raleway, 'Helvetica Neue', Helvetica, Arial, sans-serif; - font-size: 14px; - font-weight: 400; - line-height: 24px; - color: #8c979e; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - +/* + * theme.json sets the body font size, but not the reading rhythm the front end + * uses: style.css gives every block-level element a flat 24px bottom margin + * rather than the editor's default spacing. + */ .editor-styles-wrapper p, .editor-styles-wrapper ul, .editor-styles-wrapper ol, .editor-styles-wrapper table, -.editor-styles-wrapper blockquote { - margin-top: 0; - margin-bottom: 24px; -} - +.editor-styles-wrapper blockquote, .editor-styles-wrapper h1, .editor-styles-wrapper h2, .editor-styles-wrapper h3, .editor-styles-wrapper h4, .editor-styles-wrapper h5, .editor-styles-wrapper h6 { - font-family: inherit; - font-weight: 400; - color: #0e1015; margin-top: 0; margin-bottom: 24px; } -.editor-styles-wrapper h1 { - font-size: 60px; -} - -.editor-styles-wrapper h2 { - font-size: 45px; -} - -.editor-styles-wrapper h3 { - font-size: 30px; -} - -.editor-styles-wrapper h4 { - font-size: 25px; -} - -.editor-styles-wrapper h5 { - font-size: 22px; - line-height: 28px; -} - -.editor-styles-wrapper h6 { - font-size: 18px; - line-height: 25px; -} - -/* The front end drops heading sizes sharply on small screens; match it so the - editor's mobile preview is not wildly larger than the real thing. */ +/* + * Headings drop sharply on narrow screens on the front end. Without this the + * editor's mobile preview shows a 60px h1 where the visitor sees 32px. + */ @media all and (max-width: 767px) { .editor-styles-wrapper h1 { @@ -78,11 +41,7 @@ } } -.editor-styles-wrapper a { - color: #745cf9; - font-weight: 600; -} - +/* style.css blockquote: no rule, larger type, its own colour. */ .editor-styles-wrapper blockquote { overflow: hidden; padding: 32px 0; diff --git a/assets/css/welcome.css b/assets/css/welcome.css new file mode 100644 index 0000000..a8f5ea6 --- /dev/null +++ b/assets/css/welcome.css @@ -0,0 +1,139 @@ +/* + * Shapely welcome screen. + * + * Deliberately plain: this is a WordPress admin page, so it borrows core's + * type and colour rather than introducing a second visual language inside + * wp-admin. Replaces welcome-screen/css/welcome.css from the Epsilon bundle. + */ + +.shapely-welcome__title { + margin-bottom: 0.2em; +} + +.shapely-welcome__intro { + max-width: 60em; + margin-top: 0; + color: #50575e; + font-size: 14px; +} + +.shapely-welcome__tabs { + margin-bottom: 1.5em; +} + +.shapely-welcome__count { + display: inline-block; + min-width: 1.6em; + padding: 0 0.4em; + border-radius: 9px; + background: #d63638; + color: #fff; + font-size: 11px; + line-height: 1.6; + text-align: center; +} + +/* ---- card grid ---- */ + +.shapely-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 20px; + max-width: 1100px; +} + +.shapely-card { + padding: 20px; + border: 1px solid #dcdcde; + background: #fff; + border-radius: 4px; +} + +.shapely-card h3 { + margin-top: 0; +} + +.shapely-card p:last-child { + margin-bottom: 0; +} + +/* ---- recommended actions ---- */ + +.shapely-actions { + max-width: 800px; + margin: 0; + padding: 0; + list-style: none; +} + +.shapely-action { + position: relative; + margin: 0 0 16px; + padding: 20px 48px 20px 20px; + border: 1px solid #dcdcde; + background: #fff; + border-radius: 4px; +} + +.shapely-action h3 { + margin-top: 0; +} + +.shapely-action__dismiss { + position: absolute; + top: 12px; + right: 12px; + padding: 0; + border: 0; + background: none; + color: #a7aaad; + cursor: pointer; + line-height: 1; +} + +.shapely-action__dismiss:hover, +.shapely-action__dismiss:focus { + color: #d63638; +} + +.shapely-action__done { + color: #008a20; + font-weight: 600; +} + +.shapely-action__error { + color: #d63638; +} + +.shapely-empty { + max-width: 800px; + padding: 20px; + border: 1px solid #dcdcde; + background: #fff; + border-radius: 4px; +} + +/* ---- import options ---- */ + +.import-content-container { + display: none; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #f0f0f1; +} + +.import-content-container h4 { + margin: 12px 0 6px; +} + +.checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 6px 20px; +} + +.checkbox-group label { + display: inline-flex; + align-items: center; + gap: 6px; +} diff --git a/assets/js/welcome.js b/assets/js/welcome.js new file mode 100644 index 0000000..ac9225c --- /dev/null +++ b/assets/js/welcome.js @@ -0,0 +1,132 @@ +/** + * Shapely welcome screen. + * + * Replaces the Epsilon welcome screen's JS. Two behaviours matter: + * + * - The demo import posts `shapely_companion_import_content` with a + * `welcome_nonce`. That endpoint lives in the Shapely Companion plugin, so + * the action name, the `import` value and the nonce field are a fixed + * contract and must not be renamed here. + * - Plugin installation is left to core's wp.updates rather than reimplemented. + */ +( function ( $ ) { + 'use strict'; + + if ( typeof window.shapelyWelcome === 'undefined' ) { + return; + } + + var cfg = window.shapelyWelcome; + + /** + * Collect the checked import options inside a container. + * + * The old screen sent 'import-all' when more than one option was ticked, + * and the single option's own value when exactly one was. The companion + * plugin still branches on those values, so keep the same mapping. + */ + function importSelection( $container ) { + var options = []; + + $container.find( 'input[type="checkbox"][name="options"]:checked' ).each( function () { + options.push( $( this ).val() ); + } ); + + if ( ! options.length ) { + return null; + } + + return options.length === 1 ? options[ 0 ] : 'import-all'; + } + + function runImport( $button ) { + var $container = $button.closest( '.shapely-action' ); + var selection = importSelection( $container ); + + if ( null === selection ) { + // Nothing ticked: there is nothing to import. + return; + } + + $button.addClass( 'updating-message' ).prop( 'disabled', true ).text( cfg.strings.importing ); + + $.ajax( { + url: cfg.ajaxurl, + type: 'POST', + dataType: 'json', + data: { + action: 'shapely_companion_import_content', + import: selection, + nonce: cfg.importNonce + } + } ) + .done( function ( response ) { + var ok = response && ( response.success === true || ( response.data && response.data.status === true ) ); + + if ( ok ) { + $container.html( '

' + cfg.strings.imported + '

' ); + window.setTimeout( function () { + window.location.reload(); + }, 1500 ); + return; + } + + $container.append( '

' + cfg.strings.failed + '

' ); + $button.removeClass( 'updating-message' ).prop( 'disabled', false ); + } ) + .fail( function () { + $container.append( '

' + cfg.strings.failed + '

' ); + $button.removeClass( 'updating-message' ).prop( 'disabled', false ); + } ); + } + + function dismissAction( $button ) { + var $item = $button.closest( '.shapely-action' ); + var id = $item.data( 'action-id' ); + + if ( ! id ) { + return; + } + + $.ajax( { + url: cfg.ajaxurl, + type: 'POST', + dataType: 'json', + data: { + action: 'shapely_dismiss_action', + id: id, + nonce: cfg.dismissNonce + } + } ).done( function () { + $item.slideUp( 200, function () { + $item.remove(); + } ); + } ); + } + + $( function () { + $( document ).on( 'click', '[data-action="import_demo"]', function ( e ) { + e.preventDefault(); + runImport( $( this ) ); + } ); + + $( document ).on( 'click', '.shapely-action__dismiss', function ( e ) { + e.preventDefault(); + dismissAction( $( this ) ); + } ); + + // Toggle the advanced import options. + $( document ).on( 'click', '.epsilon-hidden-content-toggler', function ( e ) { + e.preventDefault(); + $( $( this ).attr( 'href' ) ).slideToggle( 150 ); + } ); + + // The core notice dismiss button is generated by wp.a11y, so hook its click. + $( document ).on( 'click', '.shapely-welcome-notice .notice-dismiss', function () { + $.post( cfg.ajaxurl, { + action: 'shapely_dismiss_notice', + nonce: cfg.dismissNonce + } ); + } ); + } ); +} )( jQuery ); diff --git a/changelog.txt b/changelog.txt index d3ff5b3..a081488 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,13 @@ += 1.3.0 = +* Removed the vendored Epsilon customizer framework. It was 144 files and 1.3 MB -- 52% of the theme -- and provided 31 toggle controls, one slider, two customizer sections and the welcome screen. All of those are now built on core WordPress APIs. The download is roughly a third smaller and the theme ships 51 PHP files instead of 88 +* Added theme.json, so the palette, type scale and layout widths have a single source of truth that the block editor reads. Colour options in the customizer still work exactly as before -- each one now overrides the matching theme.json value rather than maintaining a second palette +* Fixed "Wide width" and "Full width" doing nothing on blocks. The theme has declared support for them since 2018 without ever shipping the CSS they need +* Fixed images in post content overflowing their column. Bootstrap 3 only makes images responsive via a class that block markup never carries +* Rebuilt the welcome screen and the demo importer on core APIs. The old AJAX endpoint accepted a class name and a method name from the request and called them; dismissals now run through a single handler with a nonce and a capability check +* Fixed 11 customizer controls that would have disappeared entirely if the framework had ever failed to load, because their fallback registrations were incomplete +* Renamed the bootstrap, flexslider, owl.carousel and owl.carousel.theme asset handles to be theme-specific. Generic handles can be claimed by a plugin first, which silently suppresses the theme's own file -- the same fault that blanked every icon on sites running Elementor in 1.2.20 +* The block editor now offers the theme's palette, and the editor canvas matches the front end + = 1.2.21 = * Fixed the back-to-top arrow sitting off-centre in its button. The 36px button's content box was 22px tall and 10px wide after border and padding, so a 18px glyph with a 27px line box could not centre on either axis; measured 2.8px right and 1.9px high before, sub-pixel after * Fixed one-page section links in the menu, and the page-builder customizer, using site_url() where home_url() was meant. On installs with WordPress in its own subdirectory those pointed at /wp/ instead of the site address diff --git a/eslint.config.mjs b/eslint.config.mjs index 090afaf..5a46732 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,7 +19,6 @@ export default [ 'assets/js/**/*.min.js', 'assets/js/owl-carousel/**', 'assets/js/dev/**', - 'inc/libraries/**', ], }, js.configs.recommended, diff --git a/functions.php b/functions.php index 3d3788f..ff08b48 100644 --- a/functions.php +++ b/functions.php @@ -52,6 +52,7 @@ require_once get_template_directory() . '/inc/class-shapely.php'; require_once get_template_directory() . '/inc/class-shapely-builder.php'; require_once get_template_directory() . '/inc/block-editor.php'; +require_once get_template_directory() . '/inc/class-shapely-migrations.php'; if ( ! defined( 'SHAPELY_SETUP_LOADED' ) ) { define( 'SHAPELY_SETUP_LOADED', true ); @@ -300,7 +301,7 @@ function shapely_scripts() { $uri = get_template_directory_uri(); // Add Bootstrap default CSS - wp_enqueue_style( 'bootstrap', $uri . '/assets/css/bootstrap.min.css', array(), '3.3.7' ); + wp_enqueue_style( 'shapely-bootstrap', $uri . '/assets/css/bootstrap.min.css', array(), '3.3.7' ); /* * Registered under a theme-specific handle rather than the generic @@ -322,7 +323,7 @@ function shapely_scripts() { wp_enqueue_style( 'shapely-fonts', 'https://fonts.googleapis.com/css?family=Raleway:100,300,400,500,600,700&display=swap', array(), null ); // Add slider CSS - wp_enqueue_style( 'flexslider', $uri . '/assets/css/flexslider.css', array(), SHAPELY_VERSION ); + wp_enqueue_style( 'shapely-flexslider', $uri . '/assets/css/flexslider.css', array(), SHAPELY_VERSION ); //Add custom theme css wp_enqueue_style( 'shapely-style', get_stylesheet_uri(), array(), SHAPELY_VERSION ); @@ -349,7 +350,7 @@ function shapely_scripts() { wp_enqueue_script( 'shapely-jquery-compat', $uri . '/assets/js/jquery-compat.js', array( 'jquery' ), SHAPELY_VERSION, true ); // Add slider JS - wp_enqueue_script( 'flexslider', $uri . '/assets/js/flexslider.min.js', array( 'jquery', 'shapely-jquery-compat' ), '2.7.2', true ); + wp_enqueue_script( 'shapely-flexslider', $uri . '/assets/js/flexslider.min.js', array( 'jquery', 'shapely-jquery-compat' ), '2.7.2', true ); if ( is_page_template( 'page-templates/template-home.php' ) || is_page_template( 'page-templates/template-widget.php' ) ) { wp_enqueue_script( 'shapely-parallax', $uri . '/assets/js/parallax.min.js', array( 'jquery' ), SHAPELY_VERSION, true ); @@ -357,9 +358,9 @@ function shapely_scripts() { /** * OwlCarousel Library */ - wp_enqueue_script( 'owl.carousel', $uri . '/assets/js/owl-carousel/owl.carousel.min.js', array( 'jquery' ), '2.3.4', true ); - wp_enqueue_style( 'owl.carousel', $uri . '/assets/js/owl-carousel/owl.carousel.min.css', array(), '2.3.4' ); - wp_enqueue_style( 'owl.carousel.theme', $uri . '/assets/js/owl-carousel/owl.theme.default.css', array(), '2.3.4' ); + wp_enqueue_script( 'shapely-owl-carousel', $uri . '/assets/js/owl-carousel/owl.carousel.min.js', array( 'jquery' ), '2.3.4', true ); + wp_enqueue_style( 'shapely-owl-carousel', $uri . '/assets/js/owl-carousel/owl.carousel.min.css', array(), '2.3.4' ); + wp_enqueue_style( 'shapely-owl-carousel-theme', $uri . '/assets/js/owl-carousel/owl.theme.default.css', array(), '2.3.4' ); wp_enqueue_script( 'shapely-scripts', $uri . '/assets/js/shapely-scripts.js', array( diff --git a/inc/admin/class-shapely-welcome.php b/inc/admin/class-shapely-welcome.php new file mode 100644 index 0000000..85fe776 --- /dev/null +++ b/inc/admin/class-shapely-welcome.php @@ -0,0 +1,597 @@ +actions = isset( $config['actions'] ) ? (array) $config['actions'] : array(); + $this->plugins = isset( $config['plugins'] ) ? (array) $config['plugins'] : array(); + + add_action( 'admin_menu', array( $this, 'register_page' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); + add_action( 'admin_notices', array( $this, 'admin_notice' ) ); + add_action( 'wp_ajax_shapely_dismiss_action', array( $this, 'ajax_dismiss_action' ) ); + add_action( 'wp_ajax_shapely_dismiss_notice', array( $this, 'ajax_dismiss_notice' ) ); + } + + /** + * Tabs on the welcome screen. + * + * @return array + */ + private function tabs() { + return array( + 'getting-started' => esc_html__( 'Getting Started', 'shapely' ), + 'recommended-actions' => esc_html__( 'Recommended Actions', 'shapely' ), + 'recommended-plugins' => esc_html__( 'Recommended Plugins', 'shapely' ), + 'support' => esc_html__( 'Support', 'shapely' ), + ); + } + + /** + * Register the page under Appearance. + */ + public function register_page() { + add_theme_page( + esc_html__( 'Shapely', 'shapely' ), + esc_html__( 'About Shapely', 'shapely' ), + 'edit_theme_options', + self::PAGE, + array( $this, 'render' ) + ); + } + + /** + * Is the current screen our welcome page? + * + * @return bool + */ + private function is_welcome_screen() { + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + + return $screen && false !== strpos( (string) $screen->id, self::PAGE ); + } + + /** + * Assets. + * + * @param string $hook Current admin page. + */ + public function enqueue( $hook ) { + $on_screen = $this->is_welcome_screen(); + + // The dismissible notice appears on every admin page, so its script + // has to load there too -- but nothing else does. + if ( ! $on_screen && ! $this->should_show_notice() ) { + return; + } + + $uri = get_template_directory_uri(); + + if ( $on_screen ) { + wp_enqueue_style( 'shapely-welcome', $uri . '/assets/css/welcome.css', array(), SHAPELY_VERSION ); + // Core's plugin installer, for the one-click install buttons. + wp_enqueue_script( 'plugin-install' ); + wp_enqueue_script( 'updates' ); + add_thickbox(); + } + + wp_enqueue_script( 'shapely-welcome', $uri . '/assets/js/welcome.js', array( 'jquery' ), SHAPELY_VERSION, true ); + + wp_localize_script( + 'shapely-welcome', + 'shapelyWelcome', + array( + 'ajaxurl' => admin_url( 'admin-ajax.php' ), + /* + * welcome_nonce is the nonce the Shapely Companion plugin + * verifies for the import. Renaming it would break the + * import against every released version of the plugin. + */ + 'importNonce' => wp_create_nonce( 'welcome_nonce' ), + 'dismissNonce' => wp_create_nonce( 'shapely_dismiss' ), + 'strings' => array( + 'imported' => esc_html__( 'Demo content was imported successfully.', 'shapely' ), + 'importing' => esc_html__( 'Importing…', 'shapely' ), + 'failed' => esc_html__( 'There was an error importing the demo content.', 'shapely' ), + ), + ) + ); + } + + /** + * Actions the user has not yet dismissed or completed. + * + * @return array + */ + private function outstanding_actions() { + $dismissed = (array) get_option( self::DISMISSED_OPTION, array() ); + $out = array(); + + foreach ( $this->actions as $action ) { + if ( empty( $action['id'] ) ) { + continue; + } + if ( in_array( $action['id'], $dismissed, true ) ) { + continue; + } + if ( ! empty( $action['check'] ) ) { + continue; + } + $out[] = $action; + } + + return $out; + } + + /** + * Should the "get started" notice show? + * + * @return bool + */ + private function should_show_notice() { + if ( ! current_user_can( 'edit_theme_options' ) ) { + return false; + } + if ( get_option( 'shapely_welcome_notice_dismissed' ) ) { + return false; + } + + return true; + } + + /** + * One-time admin notice pointing at the welcome screen. + */ + public function admin_notice() { + if ( ! $this->should_show_notice() || $this->is_welcome_screen() ) { + return; + } + ?> +
+

+ ' . esc_html__( 'Open the setup page', 'shapely' ) . '' + ); + ?> +

+
+ esc_html__( 'Not allowed', 'shapely' ) ), 403 ); + } + + $id = isset( $_POST['id'] ) ? sanitize_key( wp_unslash( $_POST['id'] ) ) : ''; + + if ( '' === $id ) { + wp_send_json_error( array( 'message' => esc_html__( 'Missing action id', 'shapely' ) ), 400 ); + } + + $dismissed = (array) get_option( self::DISMISSED_OPTION, array() ); + + if ( ! in_array( $id, $dismissed, true ) ) { + $dismissed[] = $id; + update_option( self::DISMISSED_OPTION, $dismissed, false ); + } + + wp_send_json_success( array( 'id' => $id ) ); + } + + /** + * Dismiss the admin notice. + */ + public function ajax_dismiss_notice() { + check_ajax_referer( 'shapely_dismiss', 'nonce' ); + + if ( ! current_user_can( 'edit_theme_options' ) ) { + wp_send_json_error( array( 'message' => esc_html__( 'Not allowed', 'shapely' ) ), 403 ); + } + + update_option( 'shapely_welcome_notice_dismissed', 1, false ); + + wp_send_json_success(); + } + + /** + * URL for a tab. + * + * @param string $tab Tab slug. + * @return string + */ + private function tab_url( $tab ) { + return admin_url( 'themes.php?page=' . self::PAGE . '&tab=' . rawurlencode( $tab ) ); + } + + /** + * Render the page. + */ + public function render() { + if ( ! current_user_can( 'edit_theme_options' ) ) { + wp_die( esc_html__( 'You do not have permission to view this page.', 'shapely' ) ); + } + + $tabs = $this->tabs(); + // Read-only tab selection; nonce would be meaningless here. + $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'getting-started'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + if ( ! isset( $tabs[ $tab ] ) ) { + $tab = 'getting-started'; + } + + $theme = wp_get_theme( get_template() ); + ?> +
+ +

+ get( 'Version' ) ) + ); + ?> +

+

+ + + +
+ render_actions(); + break; + case 'recommended-plugins': + $this->render_plugins(); + break; + case 'support': + $this->render_support(); + break; + default: + $this->render_getting_started(); + } + ?> +
+
+ outstanding_actions() ); + ?> +
+
+

+ +

+ +

+ + + + +

+ +
+ +
+

+

+

+
+ +
+

+

+

+
+
+ outstanding_actions(); + + if ( empty( $actions ) ) { + echo '

' . esc_html__( 'No recommended actions left to perform.', 'shapely' ) . '

'; + + return; + } + + echo '