diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md
index 2917c8577b07da..d9f017b60a99e7 100644
--- a/docs/reference-guides/core-blocks.md
+++ b/docs/reference-guides/core-blocks.md
@@ -584,6 +584,15 @@ Post terms. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages
- **Supports:** anchor, color (background, gradients, link, text), spacing (margin, padding), typography (fontSize, lineHeight), ~~html~~
- **Attributes:** prefix, separator, suffix, term, textAlign
+## Time To Read
+
+Show minutes required to finish reading the post. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/post-time-to-read))
+
+- **Name:** core/post-time-to-read
+- **Category:** theme
+- **Supports:** ~~html~~, ~~multiple~~
+- **Attributes:** textAlign
+
## Post Title
Displays the title of a post, page, or any other content-type. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/post-title))
diff --git a/lib/blocks.php b/lib/blocks.php
index add72e77062cb3..ddd3d252c75702 100644
--- a/lib/blocks.php
+++ b/lib/blocks.php
@@ -90,6 +90,7 @@ function gutenberg_reregister_core_block_types() {
'post-featured-image.php' => 'core/post-featured-image',
'post-navigation-link.php' => 'core/post-navigation-link',
'post-terms.php' => 'core/post-terms',
+ 'post-time-to-read.php' => 'core/post-time-to-read',
'post-title.php' => 'core/post-title',
'query.php' => 'core/query',
'post-template.php' => 'core/post-template',
diff --git a/lib/experimental/l10n.php b/lib/experimental/l10n.php
new file mode 100644
index 00000000000000..8233bb8bb05a8f
--- /dev/null
+++ b/lib/experimental/l10n.php
@@ -0,0 +1,139 @@
+ '/<\/?[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;
+ }
+}
diff --git a/lib/load.php b/lib/load.php
index 39b0446869791a..03406015834eaa 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -109,6 +109,7 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/experimental/blocks.php';
require __DIR__ . '/experimental/navigation-theme-opt-in.php';
require __DIR__ . '/experimental/kses.php';
+require __DIR__ . '/experimental/l10n.php';
// Fonts API.
if ( ! class_exists( 'WP_Fonts' ) ) {
diff --git a/package-lock.json b/package-lock.json
index 74622d7a1a8411..5ad3f519a2542d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17373,6 +17373,7 @@
"@wordpress/server-side-render": "file:packages/server-side-render",
"@wordpress/url": "file:packages/url",
"@wordpress/viewport": "file:packages/viewport",
+ "@wordpress/wordcount": "file:packages/wordcount",
"change-case": "^4.1.2",
"classnames": "^2.3.1",
"colord": "^2.7.0",
diff --git a/packages/block-library/package.json b/packages/block-library/package.json
index dd445c6b462b05..56238ba22b7bd5 100644
--- a/packages/block-library/package.json
+++ b/packages/block-library/package.json
@@ -59,6 +59,7 @@
"@wordpress/server-side-render": "file:../server-side-render",
"@wordpress/url": "file:../url",
"@wordpress/viewport": "file:../viewport",
+ "@wordpress/wordcount": "file:../wordcount",
"change-case": "^4.1.2",
"classnames": "^2.3.1",
"colord": "^2.7.0",
diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js
index 34ba9fd5a2c8f1..317b1d4fbad5e6 100644
--- a/packages/block-library/src/index.js
+++ b/packages/block-library/src/index.js
@@ -83,6 +83,7 @@ import * as postFeaturedImage from './post-featured-image';
import * as postNavigationLink from './post-navigation-link';
import * as postTemplate from './post-template';
import * as postTerms from './post-terms';
+import * as postTimeToRead from './post-time-to-read';
import * as postTitle from './post-title';
import * as preformatted from './preformatted';
import * as pullquote from './pullquote';
@@ -197,6 +198,7 @@ const getAllBlocks = () =>
postTerms,
postNavigationLink,
postTemplate,
+ postTimeToRead,
queryPagination,
queryPaginationNext,
queryPaginationNumbers,
diff --git a/packages/block-library/src/post-time-to-read/block.json b/packages/block-library/src/post-time-to-read/block.json
new file mode 100644
index 00000000000000..33cd4674d77535
--- /dev/null
+++ b/packages/block-library/src/post-time-to-read/block.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 2,
+ "__experimental": true,
+ "name": "core/post-time-to-read",
+ "title": "Time To Read",
+ "category": "theme",
+ "description": "Show minutes required to finish reading the post.",
+ "textdomain": "default",
+ "usesContext": [ "postId", "postType" ],
+ "attributes": {
+ "textAlign": {
+ "type": "string"
+ }
+ },
+ "supports": {
+ "html": false,
+ "multiple": false
+ }
+}
diff --git a/packages/block-library/src/post-time-to-read/edit.js b/packages/block-library/src/post-time-to-read/edit.js
new file mode 100644
index 00000000000000..b9092c69952b7b
--- /dev/null
+++ b/packages/block-library/src/post-time-to-read/edit.js
@@ -0,0 +1,101 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { _x, _n, sprintf } from '@wordpress/i18n';
+import { useMemo } from '@wordpress/element';
+import {
+ AlignmentControl,
+ BlockControls,
+ 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 PostTimeToReadEdit( { attributes, setAttributes, context } ) {
+ const { textAlign } = attributes;
+ 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!'
+ );
+
+ 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 ),
+ minutesToRead
+ );
+ }, [ contentStructure, blocks ] );
+
+ const blockProps = useBlockProps( {
+ className: classnames( {
+ [ `has-text-align-${ textAlign }` ]: textAlign,
+ } ),
+ } );
+
+ return (
+ <>
+
{ minutesToReadString }
+ > + ); +} + +export default PostTimeToReadEdit; diff --git a/packages/block-library/src/post-time-to-read/icon.js b/packages/block-library/src/post-time-to-read/icon.js new file mode 100644 index 00000000000000..56b6b2b182fc26 --- /dev/null +++ b/packages/block-library/src/post-time-to-read/icon.js @@ -0,0 +1,15 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/components'; + +export default ( + +); diff --git a/packages/block-library/src/post-time-to-read/index.js b/packages/block-library/src/post-time-to-read/index.js new file mode 100644 index 00000000000000..95b379f55f0b3f --- /dev/null +++ b/packages/block-library/src/post-time-to-read/index.js @@ -0,0 +1,17 @@ +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import metadata from './block.json'; +import edit from './edit'; +import icon from './icon'; + +const { name } = metadata; +export { metadata, name }; + +export const settings = { + icon, + edit, +}; + +export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/post-time-to-read/index.php b/packages/block-library/src/post-time-to-read/index.php new file mode 100644 index 00000000000000..07761e5e759045 --- /dev/null +++ b/packages/block-library/src/post-time-to-read/index.php @@ -0,0 +1,62 @@ +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( wp_word_count( $content, $word_count_type ) / $average_reading_rate ) ); + + $minutes_to_read_string = sprintf( + /* translators: %d is the number of minutes the post will take to read. */ + _n( '%d minute', '%d minutes', $minutes_to_read ), + $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 + ); +} + +/** + * Registers the `core/post-time-to-read` block on the server. + */ +function register_block_core_post_time_to_read() { + register_block_type_from_metadata( + __DIR__ . '/post-time-to-read', + array( + 'render_callback' => 'render_block_core_post_time_to_read', + ) + ); +} +add_action( 'init', 'register_block_core_post_time_to_read' ); diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json index cdf20e557f242d..cb70d96e7c3f83 100644 --- a/packages/block-library/tsconfig.json +++ b/packages/block-library/tsconfig.json @@ -28,7 +28,8 @@ { "path": "../icons" }, { "path": "../keycodes" }, { "path": "../primitives" }, - { "path": "../url" } + { "path": "../url" }, + { "path": "../wordcount" } ], "include": [ "src/**/*.ts", "src/**/*.tsx" ] } diff --git a/phpunit/blocks/render-post-time-to-read-test.php b/phpunit/blocks/render-post-time-to-read-test.php new file mode 100644 index 00000000000000..abca2daeeeaeba --- /dev/null +++ b/phpunit/blocks/render-post-time-to-read-test.php @@ -0,0 +1,187 @@ +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' => 'core/post-time-to-read', + ); + } + + public function tear_down() { + WP_Block_Supports::$block_to_render = $this->original_block_supports; + parent::tear_down(); + } + + /** + * @covers ::render_block_core_post_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_core_post_time_to_read( $attributes, '', $block ); + $expected = '1 minute
'; + + $this->assertSame( $expected, $actual ); + } + + /** + * @covers ::render_block_core_post_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_core_post_time_to_read( $attributes, '', $block ); + $expected = '1 minute
'; + + $this->assertSame( $expected, $actual ); + } + + /** + * @covers ::render_block_core_post_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_core_post_time_to_read( $attributes, '', $block ); + $expected = '1 minute
'; + + $this->assertSame( $expected, $actual ); + } + + /** + * @covers ::render_block_core_post_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_core_post_time_to_read( $attributes, '', $block ); + $expected = '2 minutes
'; + + $this->assertSame( $expected, $actual ); + } +} diff --git a/phpunit/l10n-test.php b/phpunit/l10n-test.php new file mode 100644 index 00000000000000..4f71e9e89e2279 --- /dev/null +++ b/phpunit/l10n-test.php @@ -0,0 +1,100 @@ + array( 'shortcode' ), + ); + + $this->assertEquals( wp_word_count( $string, 'words', $settings ), $words ); + $this->assertEquals( wp_word_count( $string, 'characters_excluding_spaces', $settings ), $characters_excluding_spaces ); + $this->assertEquals( wp_word_count( $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