diff --git a/.eslintrc.js b/.eslintrc.js index 177f3cf35b8cc..0f7169f2e62f1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -466,5 +466,16 @@ module.exports = { 'react/react-in-jsx-scope': 'error', }, }, + { + files: [ 'plugins/time-to-read-block/src/**' ], + rules: { + '@wordpress/i18n-text-domain': [ + 'error', + { + allowedTextDomain: 'time-to-read-block', + }, + ], + }, + }, ], }; diff --git a/.github/workflows/check-backport-changelog.yml b/.github/workflows/check-backport-changelog.yml index 355acb37bd14d..e8fbc1a77fb0d 100644 --- a/.github/workflows/check-backport-changelog.yml +++ b/.github/workflows/check-backport-changelog.yml @@ -13,6 +13,7 @@ on: - 'phpunit/**' - '!phpunit/experimental/**' - '!phpunit/blocks/**' + - '!phpunit/plugins/**' - 'packages/**/*.php' - '!packages/block-library/**' - '!packages/e2e-tests/**' diff --git a/.wp-env.json b/.wp-env.json index 20d5597e54bbc..92267fa78da48 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -2,6 +2,9 @@ "core": "WordPress/WordPress", "plugins": [ "." ], "themes": [ "./test/emptytheme" ], + "mappings": { + "wp-content/plugins/time-to-read-block": "./plugins/time-to-read-block" + }, "env": { "tests": { "mappings": { diff --git a/bin/format-php.sh b/bin/format-php.sh new file mode 100755 index 0000000000000..a9f63c7bc74de --- /dev/null +++ b/bin/format-php.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +./vendor/bin/phpcbf --standard=phpcs.xml.dist --report-summary --report-source + +for dir in ./plugins/*/ +do + if [ -d "$dir" ] && [ -f "$dir/phpcs.xml.dist" ]; then + ./vendor/bin/phpcbf --standard="$dir/phpcs.xml.dist" --report-summary --report-source + fi +done diff --git a/bin/lint-php.sh b/bin/lint-php.sh new file mode 100755 index 0000000000000..b7cd29814396e --- /dev/null +++ b/bin/lint-php.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +./vendor/bin/phpcs --standard=phpcs.xml.dist + +for dir in ./plugins/*/ +do + if [ -d "$dir" ] && [ -f "$dir/phpcs.xml.dist" ]; then + ./vendor/bin/phpcs --standard="$dir/phpcs.xml.dist" + fi +done diff --git a/composer.json b/composer.json index 3571377bd58bd..f259dc6238500 100644 --- a/composer.json +++ b/composer.json @@ -46,8 +46,8 @@ "composer/installers": "~1.0" }, "scripts": { - "format": "phpcbf --standard=phpcs.xml.dist --report-summary --report-source", - "lint": "phpcs --standard=phpcs.xml.dist", + "format": "bash ./bin/format-php.sh", + "lint": "bash ./bin/lint-php.sh", "test": "phpunit", "test:watch": "phpunit-watcher watch < /dev/tty" } diff --git a/package-lock.json b/package-lock.json index 3b529ece9bdb5..9b31c4eb88d84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "18.7.0-rc.1", "hasInstallScript": true, "license": "GPL-2.0-or-later", + "workspaces": [ + "plugins/*" + ], "dependencies": { "@wordpress/a11y": "file:packages/a11y", "@wordpress/annotations": "file:packages/annotations", @@ -49301,6 +49304,10 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, + "node_modules/time-to-read-block": { + "resolved": "plugins/time-to-read-block", + "link": true + }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -56094,6 +56101,20 @@ "node": ">=18.12.0", "npm": ">=8.19.2" } + }, + "plugins/time-to-read-block": { + "version": "1.0.0", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/block-editor": "file:../../packages/block-editor", + "@wordpress/blocks": "file:../../packages/blocks", + "@wordpress/components": "file:../../packages/components", + "@wordpress/core-data": "file:../../packages/core-data", + "@wordpress/element": "file:../../packages/element", + "@wordpress/i18n": "file:../../packages/i18n", + "@wordpress/wordcount": "file:../../packages/wordcount", + "clsx": "^2.1.1" + } } }, "dependencies": { @@ -94139,6 +94160,19 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, + "time-to-read-block": { + "version": "file:plugins/time-to-read-block", + "requires": { + "@wordpress/block-editor": "file:../../packages/block-editor", + "@wordpress/blocks": "file:../../packages/blocks", + "@wordpress/components": "file:../../packages/components", + "@wordpress/core-data": "file:../../packages/core-data", + "@wordpress/element": "file:../../packages/element", + "@wordpress/i18n": "file:../../packages/i18n", + "@wordpress/wordcount": "file:../../packages/wordcount", + "clsx": "^2.1.1" + } + }, "timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", diff --git a/package.json b/package.json index bad9ebd48e92d..d40451d9dc9c0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ "config": { "IS_GUTENBERG_PLUGIN": true }, + "workspaces": [ + "plugins/*" + ], "dependencies": { "@wordpress/a11y": "file:packages/a11y", "@wordpress/annotations": "file:packages/annotations", @@ -268,12 +271,14 @@ "jsdom": "22.1.0" }, "scripts": { - "build": "npm run build:packages && wp-scripts build", + "build": "npm run build:packages && concurrently 'npm run build:plugins' 'wp-scripts build'", "build:analyze-bundles": "npm run build -- --webpack-bundle-analyzer", "build:package-types": "node ./bin/packages/validate-typescript-version.js && ( tsc --build || ( echo 'tsc failed. Try cleaning up first: `npm run clean:package-types`'; exit 1 ) ) && node ./bin/packages/check-build-type-declaration-files.js", "prebuild:packages": "npm run clean:packages && lerna run build", "build:packages": "npm run --silent build:package-types && node ./bin/packages/build.js", - "build:plugin-zip": "bash ./bin/build-plugin-zip.sh", + "build:plugins": "npm run build --workspace ./plugins", + "build:plugin-zip": "concurrently 'npm run build:plugin-zip:plugins' 'bash ./bin/build-plugin-zip.sh'", + "build:plugin-zip:plugins": "npm run plugin-zip --workspace ./plugins", "clean:package-types": "tsc --build --clean && rimraf \"./packages/*/build-types\"", "clean:packages": "rimraf \"./packages/*/@(build|build-module|build-style)\"", "dev": "cross-env NODE_ENV=development npm run build:packages && concurrently \"wp-scripts start\" \"npm run dev:packages\"", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 5bc7a5676e712..2ffb2a2691d3d 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -2,48 +2,8 @@ Sniffs for WordPress plugins, with minor modifications for Gutenberg - - - - *\.php$ - - - - - - - - - - - - - - - - - - - - - - - - - - /lib/compat/* - /packages/block-library/src/* - /build/block-library/* - - - - - - - - - - + + ./bin ./gutenberg.php @@ -52,56 +12,6 @@ ./phpunit ./post-content.php - - /packages/block-serialization-spec-parser/parser\.php$ - /node_modules/* - /build/* - - - /vendor/* - /test/php/gutenberg-coding-standards/* - - - /lib/compat/wordpress-*/html-api/*\.php$ - - - - /phpunit/* - /packages/block-library/src/navigation/index\.php$ - - - - - /test/gutenberg-test-themes/* - - - - - /phpunit/* - *\.asset\.php$ - - - /phpunit/* - - - - - /phpunit/* - /packages/* - /bin/generate-gutenberg-php\.php$ - - - - - - - - - - - /packages/block-library/src/*/*\.php$ @@ -156,6 +66,7 @@ + /packages/block-library/src/.+/*\.php$ /packages/block-library/src/.+/*\.php$ diff --git a/phpcs.xml.shared b/phpcs.xml.shared new file mode 100644 index 0000000000000..6ea23aa1ca10e --- /dev/null +++ b/phpcs.xml.shared @@ -0,0 +1,97 @@ + + + Sniffs defaults for all PHP files in the Gutenberg monorepo + + + + + *\.php$ + + + + + + + + + + + + + + + + + + + + + + + + + + /lib/compat/* + /packages/block-library/src/* + /build/block-library/* + + + + + + + + + + + + + /packages/block-serialization-spec-parser/parser\.php$ + /node_modules/* + /build/* + + + /vendor/* + /test/php/gutenberg-coding-standards/* + + + /lib/compat/wordpress-*/html-api/*\.php$ + + + + /phpunit/* + /packages/block-library/src/navigation/index\.php$ + + + + + /test/gutenberg-test-themes/* + + + + + /phpunit/* + *\.asset\.php$ + + + /phpunit/* + + + + + /phpunit/* + /packages/* + /bin/generate-gutenberg-php\.php$ + + + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c8dc2d34b0fed..2190345233d70 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -15,6 +15,7 @@ ./phpunit/ ./phpunit/tests/ ./phpunit/blocks/ + ./phpunit/plugins/ diff --git a/phpunit/bootstrap.php b/phpunit/bootstrap.php index 295d02978f120..dbe522763e584 100644 --- a/phpunit/bootstrap.php +++ b/phpunit/bootstrap.php @@ -64,10 +64,16 @@ /** * Manually load the plugin being tested. */ -function _manually_load_plugin() { +function _manually_load_plugins() { require dirname( __DIR__ ) . '/lib/load.php'; + + $plugin_dirs = glob( dirname( __DIR__ ) . '/plugins/*', GLOB_ONLYDIR ); + foreach ( $plugin_dirs as $dir ) { + $plugin_name = basename( $dir ); + require "$dir/$plugin_name.php"; + } } -tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' ); +tests_add_filter( 'muplugins_loaded', '_manually_load_plugins' ); /** * Adds a wp_die handler for use during tests. diff --git a/phpunit/plugins/time-to-read-block/renderBlock.php b/phpunit/plugins/time-to-read-block/renderBlock.php new file mode 100644 index 0000000000000..e6066078129b6 --- /dev/null +++ b/phpunit/plugins/time-to-read-block/renderBlock.php @@ -0,0 +1,184 @@ +post->create_and_get( + array( + 'post_type' => 'post', + 'post_title' => 'Post without content', + 'post_content' => '', + ) + ); + self::$posts[] = self::$no_content_post; + + self::$less_than_one_minute_post = self::factory()->post->create_and_get( + array( + 'post_type' => 'post', + 'post_title' => 'Post that takes less than 1 minute to read', + 'post_content' => $content, + ) + ); + self::$posts[] = self::$less_than_one_minute_post; + + self::$one_minute_post = self::factory()->post->create_and_get( + array( + 'post_type' => 'post', + 'post_title' => 'Post that takes 1 minute to read', + 'post_content' => str_repeat( $content, 2 ), + ) + ); + self::$posts[] = self::$one_minute_post; + + self::$two_minutes_post = self::factory()->post->create_and_get( + array( + 'post_type' => 'post', + 'post_title' => 'Post that takes 2 minutes to read', + 'post_content' => str_repeat( $content, 5 ), + ) + ); + self::$posts[] = self::$two_minutes_post; + } + + public static function wpTearDownAfterClass() { + foreach ( self::$posts as $post_to_delete ) { + wp_delete_post( $post_to_delete->ID, true ); + } + } + + public function set_up() { + parent::set_up(); + $this->original_block_supports = WP_Block_Supports::$block_to_render; + WP_Block_Supports::$block_to_render = array( + 'attrs' => array(), + 'blockName' => 'gutenberg/time-to-read', + ); + } + + public function tear_down() { + WP_Block_Supports::$block_to_render = $this->original_block_supports; + parent::tear_down(); + } + + /** + * @covers ::gutenberg_render_block_time_to_read + */ + public function test_no_content_post() { + global $wp_query; + + $wp_query->post = self::$no_content_post; + $GLOBALS['post'] = self::$no_content_post; + + $page_id = self::$no_content_post->ID; + $attributes = array(); + $parsed_blocks = parse_blocks( '' ); + $parsed_block = $parsed_blocks[0]; + $context = array( 'postId' => $page_id ); + $block = new WP_Block( $parsed_block, $context ); + + $actual = gutenberg_render_block_time_to_read( $attributes, '', $block ); + $expected = '
1 minute
'; + + $this->assertSame( $expected, $actual ); + } + + /** + * @covers ::gutenberg_render_block_time_to_read + */ + public function test_less_than_one_minute_post() { + global $wp_query; + + $wp_query->post = self::$less_than_one_minute_post; + $GLOBALS['post'] = self::$less_than_one_minute_post; + + $page_id = self::$less_than_one_minute_post->ID; + $attributes = array(); + $parsed_blocks = parse_blocks( '' ); + $parsed_block = $parsed_blocks[0]; + $context = array( 'postId' => $page_id ); + $block = new WP_Block( $parsed_block, $context ); + + $actual = gutenberg_render_block_time_to_read( $attributes, '', $block ); + $expected = '
1 minute
'; + + $this->assertSame( $expected, $actual ); + } + + /** + * @covers ::gutenberg_render_block_time_to_read + */ + public function test_one_minute_post() { + global $wp_query; + + $wp_query->post = self::$one_minute_post; + $GLOBALS['post'] = self::$one_minute_post; + + $page_id = self::$one_minute_post->ID; + $attributes = array(); + $parsed_blocks = parse_blocks( '' ); + $parsed_block = $parsed_blocks[0]; + $context = array( 'postId' => $page_id ); + $block = new WP_Block( $parsed_block, $context ); + + $actual = gutenberg_render_block_time_to_read( $attributes, '', $block ); + $expected = '
1 minute
'; + + $this->assertSame( $expected, $actual ); + } + + /** + * @covers ::gutenberg_render_block_time_to_read + */ + public function test_two_minutes_post() { + global $wp_query; + + $wp_query->post = self::$two_minutes_post; + $GLOBALS['post'] = self::$two_minutes_post; + + $page_id = self::$two_minutes_post->ID; + $attributes = array(); + $parsed_blocks = parse_blocks( '' ); + $parsed_block = $parsed_blocks[0]; + $context = array( 'postId' => $page_id ); + $block = new WP_Block( $parsed_block, $context ); + + $actual = gutenberg_render_block_time_to_read( $attributes, '', $block ); + $expected = '
2 minutes
'; + + $this->assertSame( $expected, $actual ); + } +} diff --git a/phpunit/plugins/time-to-read-block/wordCount.php b/phpunit/plugins/time-to-read-block/wordCount.php new file mode 100644 index 0000000000000..5766e0fedae40 --- /dev/null +++ b/phpunit/plugins/time-to-read-block/wordCount.php @@ -0,0 +1,98 @@ + array( 'shortcode' ), + ); + + $this->assertEquals( wp_word_count( $input_string, 'words', $settings ), $words ); + $this->assertEquals( wp_word_count( $input_string, 'characters_excluding_spaces', $settings ), $characters_excluding_spaces ); + $this->assertEquals( wp_word_count( $input_string, 'characters_including_spaces', $settings ), $characters_including_spaces ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_string_variations() { + return array( + 'Basic test' => array( + 'string' => 'one two three', + 'words' => 3, + 'characters_excluding_spaces' => 11, + 'characters_including_spaces' => 13, + ), + 'HTML tags' => array( + 'string' => 'one two
three', + 'words' => 3, + 'characters_excluding_spaces' => 11, + 'characters_including_spaces' => 12, + ), + 'Line breaks' => array( + 'string' => "one\ntwo\nthree", + 'words' => 3, + 'characters_excluding_spaces' => 11, + 'characters_including_spaces' => 11, + ), + 'Encoded spaces' => array( + 'string' => 'one two three', + 'words' => 3, + 'characters_excluding_spaces' => 11, + 'characters_including_spaces' => 13, + ), + 'Punctuation' => array( + 'string' => "It's two three " . json_decode( '"\u2026"' ) . ' 4?', + 'words' => 3, + 'characters_excluding_spaces' => 15, + 'characters_including_spaces' => 19, + ), + 'Em dash' => array( + 'string' => 'one' . json_decode( '"\u2014"' ) . 'two--three', + 'words' => 3, + 'characters_excluding_spaces' => 14, + 'characters_including_spaces' => 14, + ), + 'Shortcodes' => array( + 'string' => 'one [shortcode attribute="value"]two[/shortcode]three', + 'words' => 3, + 'characters_excluding_spaces' => 11, + 'characters_including_spaces' => 12, + ), + 'Astrals' => array( + 'string' => json_decode( '"\uD83D\uDCA9"' ), + 'words' => 1, + 'characters_excluding_spaces' => 1, + 'characters_including_spaces' => 1, + ), + 'HTML comment' => array( + 'string' => 'onetwo three', + 'words' => 2, + 'characters_excluding_spaces' => 11, + 'characters_including_spaces' => 12, + ), + 'HTML entity' => array( + 'string' => '> test', + 'words' => 1, + 'characters_excluding_spaces' => 5, + 'characters_including_spaces' => 6, + ), + ); + } +} diff --git a/plugins/.npmpackagejsonlintrc.json b/plugins/.npmpackagejsonlintrc.json new file mode 100644 index 0000000000000..015fdcac63357 --- /dev/null +++ b/plugins/.npmpackagejsonlintrc.json @@ -0,0 +1,7 @@ +{ + "extends": "@wordpress/npm-package-json-lint-config", + "rules": { + "require-publishConfig": "error", + "valid-values-author": [ "error", [ "WordPress.org" ] ] + } +} diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000000000..ebbfcdf04f984 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,246 @@ +# Canonical Plugins + +This folder contains additional community plugins that are packaged separately from the Gutenberg plugin. These plugins are distributed in [the WordPress plugins repository](https://wordpress.org/plugins/), and authored by WordPress.org. Anyone is welcome to contribute to these plugins--they are maintained by the community. + +Plugins here should follow the same coding, development, testing, and quality standards as the Gutenberg plugin. Scripts and Github workflows are shared across the repo so that all code (from both Gutenberg and these plugins) is checked across the repo with every pull request. + +However, plugins should be standalone and work independently from one another, with or without the Gutenberg plugin enabled. + +Currently, the main use for canonical plugins in this repository is for single block plugins. + +Note that `PLUGIN_NAME` and `PLUGIN-SLUG` are used here as placeholders and should be replaced with the actual plugin name and slug in the following example code. + +## Folder structure + +`plugins/PLUGIN-SLUG/` directory: +``` +- .npmrc +- assets/ + - screenshot-*.png +- block.json (for block plugins) +- package.json +- phpcs.xml.dist +- readme.txt +- src/ + - index.js + - style.scss +- PLUGIN-SLUG.php +``` + +### Plugin Header + +The header in the plugin's main .php file should use the [standard plugin headers](https://developer.wordpress.org/plugins/plugin-basics/header-requirements/), with the author as "WordPress.org". + +```php + + + Includes shared sniffs for Gutenberg, with modifications. + + + + + + + + + + + + + ./ + +``` + +#### ESLint + +Add a new override for the i18n text domain in the main `.eslintrc.js` file, at the root of the repo. + +```js +... +module.exports = { + ... + overrides: { + ... + { + files: [ 'plugins/PLUGIN-SLUG/src/**' ], + rules: { + '@wordpress/i18n-text-domain': [ + 'error', + { + allowedTextDomain: 'PLUGIN-SLUG', + }, + ], + }, + }, + } +} +``` + +## Automated testing + +Plugins should have a full suite of automated tests that help ensure code changes do not introduce bugs and regressions. + +### JavaScript unit testing + +JS test files should be added in a `tests/` directory located in the plugin's `src/` folder, adjacent to the code begin tested. JS files within a `tests/` directory will be run automatically. + +See the [JavaScript testing documentation](https://github.com/WordPress/gutenberg/blob/trunk/docs/contributors/code/testing-overview.md) for more details. + +### PHPUnit testing + +PHP test files should follow the [core PHPUnit test guidelines](https://make.wordpress.org/core/handbook/testing/automated-testing/writing-phpunit-tests/). + +- Place test files in `phpunit/plugins/PLUGIN-SLUG/`. +- Test class names should start with `Tests_Plugins_Plugin_Name_`, cooresponding to the path and name of the file. + +See the [PHP testing documentation](https://github.com/WordPress/gutenberg/blob/trunk/docs/contributors/code/testing-overview.md#php-testing) for more details. + +### End-to-end testing + +End-to-end (e2e) tests should be placed in `tests/e2e/specs/plugins/`, named `PLUGIN-SLUG.spec.js`, and use the existing testing utilites. + +Be sure to disable the Gutenberg plugin and activate the plugin being tested before each set of tests, and the inverse when the tests are complete, to ensure that Gutenberg and the plugin work independently of each other. + +```js +test.describe( 'My Plugin', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( 'PLUGIN-SLUG' ); + await requestUtils.deactivatePlugin( 'gutenberg' ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( 'PLUGIN-SLUG' ); + await requestUtils.activatePlugin( 'gutenberg' ); + } ); +``` + +See the [end-to-end testing documentation](https://github.com/WordPress/gutenberg/blob/trunk/docs/contributors/code/testing-overview.md#end-to-end-testing) for more details. + +### Integration testing + +Integration tests are JavaScript tests run in a testing environment that loads a special version of the block editor. They are an alternative to end-to-end tests that can be useful for testing block UI without the overhead of loading all of WordPress in a brower environment. + +See the [Integration testing documentation](https://github.com/WordPress/gutenberg/blob/trunk/docs/contributors/code/testing-overview.md#integration-testing-for-block-ui) for more details. + +### Test fixtures + +For block plugins, place an `.html` file that contains example HTML markup for the block. + +`test/integration/fixtures/blocks/gutenberg__time-to-read.html` +```html + +``` + +Additionally, a separate fixture should be added for each block deprecation that contains markup for the deprecated version of the block. + +Then run `npm run fixtures:regenerate` to generate the remaining fixture files for the block. + +See the [block test fixtures documentation](https://github.com/WordPress/gutenberg/tree/trunk/test/integration/fixtures/blocks) for more details. diff --git a/plugins/time-to-read-block/.npmrc b/plugins/time-to-read-block/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/plugins/time-to-read-block/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/plugins/time-to-read-block/assets/screenshot-1.png b/plugins/time-to-read-block/assets/screenshot-1.png new file mode 100644 index 0000000000000..9f6fe56133da3 Binary files /dev/null and b/plugins/time-to-read-block/assets/screenshot-1.png differ diff --git a/plugins/time-to-read-block/assets/screenshot-2.png b/plugins/time-to-read-block/assets/screenshot-2.png new file mode 100644 index 0000000000000..e6cd5323949ac Binary files /dev/null and b/plugins/time-to-read-block/assets/screenshot-2.png differ diff --git a/plugins/time-to-read-block/block.json b/plugins/time-to-read-block/block.json new file mode 100644 index 0000000000000..9dd3d3de60648 --- /dev/null +++ b/plugins/time-to-read-block/block.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "gutenberg/time-to-read", + "title": "Time To Read", + "category": "theme", + "description": "Show the estimated time it will take to read the current post.", + "textdomain": "time-to-read-block", + "usesContext": [ "postId", "postType" ], + "attributes": {}, + "supports": { + "color": { + "gradients": true, + "__experimentalDefaultControls": { + "background": true, + "text": true + } + }, + "html": false, + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + }, + "typography": { + "fontSize": true, + "lineHeight": true, + "textAlign": true, + "__experimentalFontFamily": true, + "__experimentalFontWeight": true, + "__experimentalFontStyle": true, + "__experimentalTextTransform": true, + "__experimentalTextDecoration": true, + "__experimentalLetterSpacing": true, + "__experimentalDefaultControls": { + "fontSize": true + } + }, + "interactivity": { + "clientNavigation": true + } + }, + "editorScript": "file:./build/index.js", + "style": "file:./build/style.css" +} diff --git a/plugins/time-to-read-block/package.json b/plugins/time-to-read-block/package.json new file mode 100644 index 0000000000000..a7355bde9fb57 --- /dev/null +++ b/plugins/time-to-read-block/package.json @@ -0,0 +1,40 @@ +{ + "private": true, + "name": "time-to-read-block", + "version": "1.0.0", + "description": "A block that shows the estimated time it will take to read the current post.", + "author": "WordPress.org", + "license": "GPL-2.0-or-later", + "keywords": [ + "blocks", + "wordpress" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/trunk/plugins/time-to-read-block", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "plugins/time-to-read-block" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "dependencies": { + "@wordpress/block-editor": "file:../../packages/block-editor", + "@wordpress/blocks": "file:../../packages/blocks", + "@wordpress/components": "file:../../packages/components", + "@wordpress/core-data": "file:../../packages/core-data", + "@wordpress/element": "file:../../packages/element", + "@wordpress/i18n": "file:../../packages/i18n", + "@wordpress/wordcount": "file:../../packages/wordcount", + "clsx": "^2.1.1" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "wp-scripts build", + "plugin-zip": "wp-scripts plugin-zip", + "start": "wp-scripts start" + } +} diff --git a/plugins/time-to-read-block/phpcs.xml.dist b/plugins/time-to-read-block/phpcs.xml.dist new file mode 100644 index 0000000000000..6ff1fdf10b236 --- /dev/null +++ b/plugins/time-to-read-block/phpcs.xml.dist @@ -0,0 +1,17 @@ + + + Includes shared sniffs for Gutenberg, with modifications. + + + + + + + + + + + + + ./ + diff --git a/plugins/time-to-read-block/readme.txt b/plugins/time-to-read-block/readme.txt new file mode 100644 index 0000000000000..6cd953b5b02cd --- /dev/null +++ b/plugins/time-to-read-block/readme.txt @@ -0,0 +1,47 @@ +=== Time to Read === +Contributors: WordPress.org +Tags: block +Requires at least: 6.4 +Tested up to: 6.5 +Stable tag: 1.0.0 +License: GPL-2.0-or-later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +A block that shows the estimated time it will take to read the current post or page. + +== Description == + +A block that shows the estimated time it will take to read the current post or page. The time is calculated based on the content length. + +== Installation == + +Automatic installation: + +- Open any post or page in the WordPress editor. +- Click the block inserter (plus icon: +), at the top of editor, to add a new block. +- Type "Time to Read" in the search field. +- Click on the plugin in the search results list to install, activate it, and insert the block. +- If you change your mind, you can deactivate and/or delete the plugin from the Plugins menu of the WordPress admin dashboard (any existing Time to Read blocks in your content will no longer be displayed). + +Manual Installation: + +- Download the plugin from https://wordpress.org/plugins/time-to-read-block and save the .zip file to your computer. +- Go to Plugins > Add New Plugin in the WordPress admin dashboard. +- Click on "Upload Plugin" at the top of the page and select the plugin .zip file you downloaded. +- Alternatively, you can upload the decompressed .zip file to the /wp-content/plugins/ directory on your server, via SFTP/FTP. + +== Frequently Asked Questions == + += How is the time to read value calculated? = + +It is an average based on the word or character count in the post/page content, depending on the type of language. See https://irisreading.com/average-reading-speed-in-various-languages/ + +== Screenshots == + +1. Time to Read block at the top of a post. +2. Time to read block editor view and settings. + +== Changelog == + += 1.0.0 = +* Release diff --git a/plugins/time-to-read-block/src/edit.js b/plugins/time-to-read-block/src/edit.js new file mode 100644 index 0000000000000..705ca602c99c8 --- /dev/null +++ b/plugins/time-to-read-block/src/edit.js @@ -0,0 +1,81 @@ +/** + * WordPress dependencies + */ +import { _x, _n, sprintf } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; +import { useBlockProps } from '@wordpress/block-editor'; +import { __unstableSerializeAndClean } from '@wordpress/blocks'; +import { useEntityProp, useEntityBlockEditor } from '@wordpress/core-data'; +import { count as wordCount } from '@wordpress/wordcount'; + +/** + * Average reading rate - based on average taken from + * https://irisreading.com/average-reading-speed-in-various-languages/ + * (Characters/minute used for Chinese rather than words). + */ +const AVERAGE_READING_RATE = 189; + +function TimeToReadEdit( { context } ) { + const { postId, postType } = context; + + const [ contentStructure ] = useEntityProp( + 'postType', + postType, + 'content', + postId + ); + + const [ blocks ] = useEntityBlockEditor( 'postType', postType, { + id: postId, + } ); + + const minutesToReadString = useMemo( () => { + // Replicates the logic found in getEditedPostContent(). + let content; + if ( contentStructure instanceof Function ) { + content = contentStructure( { blocks } ); + } else if ( blocks ) { + // If we have parsed blocks already, they should be our source of truth. + // Parsing applies block deprecations and legacy block conversions that + // unparsed content will not have. + content = __unstableSerializeAndClean( blocks ); + } else { + content = contentStructure; + } + + /* + * translators: If your word count is based on single characters (e.g. East Asian characters), + * enter 'characters_excluding_spaces' or 'characters_including_spaces'. Otherwise, enter 'words'. + * Do not translate into your own language. + */ + const wordCountType = _x( + 'words', + 'Word count type. Do not translate!', + 'time-to-read-block' + ); + + const minutesToRead = Math.max( + 1, + Math.round( + wordCount( content, wordCountType ) / AVERAGE_READING_RATE + ) + ); + + return sprintf( + /* translators: %d is the number of minutes the post will take to read. */ + _n( + '%d minute', + '%d minutes', + minutesToRead, + 'time-to-read-block' + ), + minutesToRead + ); + }, [ contentStructure, blocks ] ); + + const blockProps = useBlockProps(); + + return
{ minutesToReadString }
; +} + +export default TimeToReadEdit; diff --git a/plugins/time-to-read-block/src/icon.js b/plugins/time-to-read-block/src/icon.js new file mode 100644 index 0000000000000..56b6b2b182fc2 --- /dev/null +++ b/plugins/time-to-read-block/src/icon.js @@ -0,0 +1,15 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/components'; + +export default ( + + + +); diff --git a/plugins/time-to-read-block/src/index.js b/plugins/time-to-read-block/src/index.js new file mode 100644 index 0000000000000..c82c694ac2fe9 --- /dev/null +++ b/plugins/time-to-read-block/src/index.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; +/** + * Internal dependencies + */ +import metadata from '../block.json'; +import edit from './edit'; +import icon from './icon'; + +import './style.scss'; + +const { name } = metadata; + +export const settings = { + icon, + edit, +}; + +registerBlockType( name, settings ); diff --git a/plugins/time-to-read-block/src/style.scss b/plugins/time-to-read-block/src/style.scss new file mode 100644 index 0000000000000..9d10408a599ed --- /dev/null +++ b/plugins/time-to-read-block/src/style.scss @@ -0,0 +1,4 @@ +.wp-block-post-time-to-read { + // This block has customizable padding, border-box makes that more predictable. + box-sizing: border-box; +} diff --git a/plugins/time-to-read-block/time-to-read-block.php b/plugins/time-to-read-block/time-to-read-block.php new file mode 100644 index 0000000000000..22019f3c67867 --- /dev/null +++ b/plugins/time-to-read-block/time-to-read-block.php @@ -0,0 +1,178 @@ +context['postId'] ) ) { + return ''; + } + + $content = get_the_content(); + + /* + * Average reading rate - based on average taken from + * https://irisreading.com/average-reading-speed-in-various-languages/ + * (Characters/minute used for Chinese rather than words). + */ + $average_reading_rate = 189; + + $word_count_type = wp_get_word_count_type(); + + $minutes_to_read = max( 1, (int) round( gutenberg_time_to_read_word_count( $content, $word_count_type ) / $average_reading_rate ) ); + + $minutes_to_read_string = sprintf( + /* translators: %s is the number of minutes the post will take to read. */ + _n( '%s minute', '%s minutes', $minutes_to_read, 'time-to-read-block' ), + $minutes_to_read + ); + + $align_class_name = empty( $attributes['textAlign'] ) ? '' : "has-text-align-{$attributes['textAlign']}"; + + $wrapper_attributes = get_block_wrapper_attributes( array( 'class' => $align_class_name ) ); + + return sprintf( + '
%2$s
', + $wrapper_attributes, + $minutes_to_read_string + ); +} + +/** + * Count words or characters in a provided text string. + * + * @param string $text Text to count elements in. + * @param string $type The type of count. Accepts 'words', 'characters_excluding_spaces', or 'characters_including_spaces'. + * @param array $settings { + * Optional. Array of arguments used to overrides for settings. + * + * @type string $html_regexp Optional. Regular expression to find HTML elements. + * @type string $html_comment_regexp Optional. Regular expression to find HTML comments. + * @type string $space_regexp Optional. Regular expression to find irregular space + * characters. + * @type string $html_entity_regexp Optional. Regular expression to find HTML entities. + * @type string $connector_regexp Optional. Regular expression to find connectors that + * split words. + * @type string $remove_regexp Optional. Regular expression to find remove unwanted + * characters to reduce false-positives. + * @type string $astral_regexp Optional. Regular expression to find unwanted + * characters when searching for non-words. + * @type string $words_regexp Optional. Regular expression to find words by spaces. + * @type string $characters_excluding_spaces_regexp Optional. Regular expression to find characters which + * are non-spaces. + * @type string $characters_including_spaces_regexp Optional. Regular expression to find characters + * including spaces. + * @type array $shortcodes Optional. Array of shortcodes that should be removed + * from the text. + * } + * @return int The word or character count. + */ +function gutenberg_time_to_read_word_count( $text, $type, $settings = array() ) { + $defaults = array( + 'html_regexp' => '/<\/?[a-z][^>]*?>/i', + 'html_comment_regexp' => '//', + 'space_regexp' => '/ | /i', + 'html_entity_regexp' => '/&\S+?;/', + 'connector_regexp' => "/--|\x{2014}/u", + 'remove_regexp' => "/[\x{0021}-\x{0040}\x{005B}-\x{0060}\x{007B}-\x{007E}\x{0080}-\x{00BF}\x{00D7}\x{00F7}\x{2000}-\x{2BFF}\x{2E00}-\x{2E7F}]/u", + 'astral_regexp' => "/[\x{010000}-\x{10FFFF}]/u", + 'words_regexp' => '/\S\s+/u', + 'characters_excluding_spaces_regexp' => '/\S/u', + 'characters_including_spaces_regexp' => "/[^\f\n\r\t\v\x{00AD}\x{2028}\x{2029}]/u", + 'shortcodes' => array(), + ); + + $count = 0; + + if ( ! $text ) { + return $count; + } + + $settings = wp_parse_args( $settings, $defaults ); + + // If there are any shortcodes, add this as a shortcode regular expression. + if ( is_array( $settings['shortcodes'] ) && ! empty( $settings['shortcodes'] ) ) { + $settings['shortcodes_regexp'] = '/\\[\\/?(?:' . implode( '|', $settings['shortcodes'] ) . ')[^\\]]*?\\]/'; + } + + // Sanitize type to one of three possibilities: 'words', 'characters_excluding_spaces' or 'characters_including_spaces'. + if ( 'characters_excluding_spaces' !== $type && 'characters_including_spaces' !== $type ) { + $type = 'words'; + } + + $text .= "\n"; + + // Replace all HTML with a new-line. + $text = preg_replace( $settings['html_regexp'], "\n", $text ); + + // Remove all HTML comments. + $text = preg_replace( $settings['html_comment_regexp'], '', $text ); + + // If a shortcode regular expression has been provided use it to remove shortcodes. + if ( ! empty( $settings['shortcodes_regexp'] ) ) { + $text = preg_replace( $settings['shortcodes_regexp'], "\n", $text ); + } + + // Normalize non-breaking space to a normal space. + $text = preg_replace( $settings['space_regexp'], ' ', $text ); + + if ( 'words' === $type ) { + // Remove HTML Entities. + $text = preg_replace( $settings['html_entity_regexp'], '', $text ); + + // Convert connectors to spaces to count attached text as words. + $text = preg_replace( $settings['connector_regexp'], ' ', $text ); + + // Remove unwanted characters. + $text = preg_replace( $settings['remove_regexp'], '', $text ); + } else { + // Convert HTML Entities to "a". + $text = preg_replace( $settings['html_entity_regexp'], 'a', $text ); + + // Remove surrogate points. + $text = preg_replace( $settings['astral_regexp'], 'a', $text ); + } + + // Match with the selected type regular expression to count the items. + preg_match_all( $settings[ $type . '_regexp' ], $text, $matches ); + + if ( $matches ) { + return count( $matches[0] ); + } + + return $count; +} + + +/** + * Registers the `gutenberg/time-to-read-read` block on the server. + */ +function gutenberg_register_time_to_read_block() { + register_block_type( + __DIR__ . '/block.json', + array( + 'render_callback' => 'gutenberg_render_block_time_to_read', + ) + ); +} + +add_action( 'init', 'gutenberg_register_time_to_read_block' ); diff --git a/test/e2e/specs/plugins/time-to-read-block.spec.js b/test/e2e/specs/plugins/time-to-read-block.spec.js new file mode 100644 index 0000000000000..b26a33fc00bc8 --- /dev/null +++ b/test/e2e/specs/plugins/time-to-read-block.spec.js @@ -0,0 +1,67 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Time to Read Block Plugin', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( 'time-to-read-block' ); + await requestUtils.deactivatePlugin( 'gutenberg' ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts(); + await requestUtils.deactivatePlugin( 'time-to-read-block' ); + await requestUtils.activatePlugin( 'gutenberg' ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost( { + title: 'Time to Read Block Test', + content: longerPostContent, + } ); + } ); + + test( 'should display the estimated time to read the post after being inserted', async ( { + editor, + page, + } ) => { + // Inserting a time to read block. + await editor.insertBlock( { + name: 'gutenberg/time-to-read', + } ); + + const timeToReadBlock = editor.canvas.locator( + 'role=document [name="Block: Time to Read"i]' + ); + await expect( + timeToReadBlock, + 'should be visible in the editor' + ).toBeVisible(); + await expect( + timeToReadBlock, + 'should display the estimated time to read the post in the editor' + ).toHaveText( '3 minutes' ); + + // Viewing block in the front end. + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + const timeToRead = page.locator( '.wp-block-gutenberg-time-to-read' ); + await expect( + timeToRead, + 'should be visible on the front end' + ).toBeVisible(); + await expect( + timeToRead, + 'should display the estimated time to read on the front end' + ).toHaveText( '3 minutes' ); + } ); +} ); + +const longerPostContent = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Pretium vulputate sapien nec sagittis aliquam malesuada bibendum. Imperdiet proin fermentum leo vel orci porta non pulvinar. Commodo viverra maecenas accumsan lacus vel. Neque viverra justo nec ultrices dui sapien eget mi proin. Dictumst quisque sagittis purus sit amet volutpat consequat. Eleifend donec pretium vulputate sapien nec sagittis. Morbi leo urna molestie at elementum eu. Duis convallis convallis tellus id. Vestibulum lorem sed risus ultricies tristique nulla aliquet. Et netus et malesuada fames ac. Sed adipiscing diam donec adipiscing tristique risus nec.\n' + + 'Elit eget gravida cum sociis natoque. Massa tincidunt nunc pulvinar sapien et ligula ullamcorper. Vel pretium lectus quam id leo in vitae turpis. A diam maecenas sed enim ut. At risus viverra adipiscing at in tellus integer. Tristique et egestas quis ipsum suspendisse ultrices gravida dictum. Semper risus in hendrerit gravida rutrum quisque non. Montes nascetur ridiculus mus mauris vitae ultricies. Id neque aliquam vestibulum morbi blandit cursus risus at ultrices. Ligula ullamcorper malesuada proin libero nunc consequat. Sodales ut etiam sit amet nisl purus in. Adipiscing elit duis tristique sollicitudin.\n' + + 'In est ante in nibh mauris cursus mattis molestie a. Adipiscing elit ut aliquam purus sit amet luctus venenatis. Duis convallis convallis tellus id. Pretium quam vulputate dignissim suspendisse in. Elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus at augue. Tempor orci eu lobortis elementum nibh tellus molestie nunc non. Dui sapien eget mi proin sed libero enim sed. Nec feugiat nisl pretium fusce id velit ut tortor. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Sed enim ut sem viverra. In egestas erat imperdiet sed euismod nisi porta lorem. Ut sem nulla pharetra diam sit amet nisl suscipit. Volutpat consequat mauris nunc congue nisi vitae suscipit. Et magnis dis parturient montes nascetur ridiculus mus mauris vitae. Enim nunc faucibus a pellentesque sit. Volutpat lacus laoreet non curabitur gravida arcu ac. Est ante in nibh mauris cursus. Integer eget aliquet nibh praesent tristique magna sit. Tempus quam pellentesque nec nam. Orci phasellus egestas tellus rutrum tellus pellentesque eu.\n' + + 'Amet mauris commodo quis imperdiet. Pharetra massa massa ultricies mi quis hendrerit. Amet nulla facilisi morbi tempus iaculis urna id volutpat. Nunc sed id semper risus in hendrerit gravida rutrum quisque. Nec feugiat in fermentum posuere urna nec tincidunt praesent. Viverra nibh cras pulvinar mattis. At erat pellentesque adipiscing commodo. Parturient montes nascetur ridiculus mus mauris vitae ultricies. Sed vulputate odio ut enim blandit volutpat maecenas volutpat blandit. Sem fringilla ut morbi tincidunt augue interdum velit.\n' + + 'Tellus rutrum tellus pellentesque eu tincidunt tortor. Egestas fringilla phasellus faucibus scelerisque eleifend donec pretium vulputate. Netus et malesuada fames ac. Ac felis donec et odio. Iaculis eu non diam phasellus. Diam donec adipiscing tristique risus. Odio facilisis mauris sit amet massa vitae tortor condimentum. Diam in arcu cursus euismod quis. Condimentum vitae sapien pellentesque habitant morbi tristique senectus et netus. Posuere urna nec tincidunt praesent. Fermentum odio eu feugiat pretium nibh ipsum consequat nisl. Duis ut diam quam nulla porttitor massa. Commodo ullamcorper a lacus vestibulum. Gravida arcu ac tortor dignissim convallis. Aliquam faucibus purus in massa tempor nec feugiat nisl. Vitae tempus quam pellentesque nec.'; diff --git a/test/integration/fixtures/blocks/gutenberg__time-to-read.html b/test/integration/fixtures/blocks/gutenberg__time-to-read.html new file mode 100644 index 0000000000000..33376106c40cc --- /dev/null +++ b/test/integration/fixtures/blocks/gutenberg__time-to-read.html @@ -0,0 +1 @@ + diff --git a/test/integration/fixtures/blocks/gutenberg__time-to-read.json b/test/integration/fixtures/blocks/gutenberg__time-to-read.json new file mode 100644 index 0000000000000..31000edd70a4b --- /dev/null +++ b/test/integration/fixtures/blocks/gutenberg__time-to-read.json @@ -0,0 +1,8 @@ +[ + { + "name": "gutenberg/time-to-read", + "isValid": true, + "attributes": {}, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/gutenberg__time-to-read.parsed.json b/test/integration/fixtures/blocks/gutenberg__time-to-read.parsed.json new file mode 100644 index 0000000000000..f05b778077f2a --- /dev/null +++ b/test/integration/fixtures/blocks/gutenberg__time-to-read.parsed.json @@ -0,0 +1,9 @@ +[ + { + "blockName": "gutenberg/time-to-read", + "attrs": {}, + "innerBlocks": [], + "innerHTML": "", + "innerContent": [] + } +] diff --git a/test/integration/fixtures/blocks/gutenberg__time-to-read.serialized.html b/test/integration/fixtures/blocks/gutenberg__time-to-read.serialized.html new file mode 100644 index 0000000000000..33376106c40cc --- /dev/null +++ b/test/integration/fixtures/blocks/gutenberg__time-to-read.serialized.html @@ -0,0 +1 @@ + diff --git a/test/integration/full-content/full-content.test.js b/test/integration/full-content/full-content.test.js index 4522dd7b88e45..d8cd59498fbef 100644 --- a/test/integration/full-content/full-content.test.js +++ b/test/integration/full-content/full-content.test.js @@ -10,6 +10,7 @@ import { format } from 'util'; import { getBlockTypes, parse, + registerBlockType, serialize, unstable__bootstrapServerSideBlockDefinitions, // eslint-disable-line camelcase } from '@wordpress/blocks'; @@ -60,16 +61,16 @@ const normalizeParsedBlocks = ( blocks ) => describe( 'full post content fixture', () => { beforeAll( () => { - const blockMetadataFiles = glob.sync( + const coreBlockMetadataFiles = glob.sync( 'packages/block-library/src/*/block.json' ); - const blockDefinitions = Object.fromEntries( - blockMetadataFiles.map( ( file ) => { + const coreBlockDefinitions = Object.fromEntries( + coreBlockMetadataFiles.map( ( file ) => { const { name, ...metadata } = require( file ); return [ name, metadata ]; } ) ); - unstable__bootstrapServerSideBlockDefinitions( blockDefinitions ); + unstable__bootstrapServerSideBlockDefinitions( coreBlockDefinitions ); registerCoreBlocks(); // Form-related blocks will not be registered unless they are opted @@ -87,6 +88,17 @@ describe( 'full post content fixture', () => { enableFSEBlocks: true, } ); } + + // Bootstrap and register block plugins. + const blockPluginMetadataFiles = glob.sync( 'plugins/*/block.json' ); + const blockPluginDefinitions = Object.fromEntries( + blockPluginMetadataFiles.map( ( file ) => { + const metadata = require( file ); + registerBlockType( metadata ); + return [ metadata.ame, metadata ]; + } ) + ); + unstable__bootstrapServerSideBlockDefinitions( blockPluginDefinitions ); } ); let spacer = 4;