diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 2917c8577b07d..d9f017b60a99e 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 add72e77062cb..ddd3d252c7570 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 0000000000000..8233bb8bb05a8 --- /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 39b0446869791..03406015834ea 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 74622d7a1a841..5ad3f519a2542 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 dd445c6b462b0..56238ba22b7bd 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 34ba9fd5a2c8f..317b1d4fbad5e 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 0000000000000..33cd4674d7753 --- /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 0000000000000..b9092c69952b7 --- /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 ( + <> + + { + setAttributes( { textAlign: nextAlign } ); + } } + /> + +

{ 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 0000000000000..56b6b2b182fc2 --- /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 0000000000000..95b379f55f0b3 --- /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 0000000000000..07761e5e75904 --- /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 cdf20e557f242..cb70d96e7c3f8 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 0000000000000..abca2daeeeaeb --- /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 0000000000000..4f71e9e89e227 --- /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
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/test/integration/fixtures/blocks/core__post-time-to-read.html b/test/integration/fixtures/blocks/core__post-time-to-read.html new file mode 100644 index 0000000000000..d3e2d54b8e40a --- /dev/null +++ b/test/integration/fixtures/blocks/core__post-time-to-read.html @@ -0,0 +1 @@ + diff --git a/test/integration/fixtures/blocks/core__post-time-to-read.json b/test/integration/fixtures/blocks/core__post-time-to-read.json new file mode 100644 index 0000000000000..535fc5f158fd0 --- /dev/null +++ b/test/integration/fixtures/blocks/core__post-time-to-read.json @@ -0,0 +1,8 @@ +[ + { + "name": "core/post-time-to-read", + "isValid": true, + "attributes": {}, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__post-time-to-read.parsed.json b/test/integration/fixtures/blocks/core__post-time-to-read.parsed.json new file mode 100644 index 0000000000000..ac1e6486dfac5 --- /dev/null +++ b/test/integration/fixtures/blocks/core__post-time-to-read.parsed.json @@ -0,0 +1,9 @@ +[ + { + "blockName": "core/post-time-to-read", + "attrs": {}, + "innerBlocks": [], + "innerHTML": "", + "innerContent": [] + } +] diff --git a/test/integration/fixtures/blocks/core__post-time-to-read.serialized.html b/test/integration/fixtures/blocks/core__post-time-to-read.serialized.html new file mode 100644 index 0000000000000..d3e2d54b8e40a --- /dev/null +++ b/test/integration/fixtures/blocks/core__post-time-to-read.serialized.html @@ -0,0 +1 @@ +