diff --git a/.travis.yml b/.travis.yml index a9652ccc33e4a..9cbdd66250e30 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,71 @@ -language: node_js -node_js: - - "node" +sudo: false + +language: php + +notifications: + email: + on_success: never + on_failure: change + +cache: + directories: + - vendor + - $HOME/.composer/cache + +before_install: + - nvm install 7 && nvm use 7 + +matrix: + include: + - php: 7.1 + env: WP_VERSION=latest + - php: 5.6 + env: WP_VERSION=latest + - php: 5.2 + env: WP_VERSION=latest + - php: 5.6 + env: TRAVISCI=phpcs + - php: 7.1 + env: TRAVISCI=js + +before_script: + - export PATH="$HOME/.composer/vendor/bin:$PATH" + - | + if [[ ! -z "$WP_VERSION" ]] ; then + bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION + if [[ ${TRAVIS_PHP_VERSION:0:2} == "5." ]]; then + mkdir -p $HOME/phpunit-bin + wget -O $HOME/phpunit-bin/phpunit https://phar.phpunit.de/phpunit-4.8.phar + chmod +x $HOME/phpunit-bin/phpunit + export PATH=$PATH:$HOME/phpunit-bin/ + else + composer global require "phpunit/phpunit=5.7.*" + fi + fi + - | + if [[ "$TRAVISCI" == "phpcs" ]] ; then + composer global require wp-coding-standards/wpcs + phpcs --config-set installed_paths $HOME/.composer/vendor/wp-coding-standards/wpcs + fi + script: - - "npm run ci" + - | + if [[ ! -z "$WP_VERSION" ]] ; then + npm install || exit 1 + npm run build || exit 1 + phpunit || exit 1 + WP_MULTISITE=1 phpunit || exit 1 + fi + - | + if [[ "$TRAVISCI" == "phpcs" ]] ; then + find . \ + -not \( -path './node_modules' \) \ + -not \( -path './vendor' \) \ + -name '*.php' \ + | xargs -d'\n' phpcs --standard=phpcs.ruleset.xml -s + fi + - | + if [[ "$TRAVISCI" == "js" ]] ; then + npm install || exit 1 + npm run ci || exit 1 + fi diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh new file mode 100755 index 0000000000000..73bb4c787eb2c --- /dev/null +++ b/bin/install-wp-tests.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash + +if [ $# -lt 3 ]; then + echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" + exit 1 +fi + +DB_NAME=$1 +DB_USER=$2 +DB_PASS=$3 +DB_HOST=${4-localhost} +WP_VERSION=${5-latest} +SKIP_DB_CREATE=${6-false} + +WP_TESTS_DIR=${WP_TESTS_DIR-/tmp/wordpress-tests-lib} +WP_CORE_DIR=${WP_CORE_DIR-/tmp/wordpress/} + +download() { + if [ `which curl` ]; then + curl -s "$1" > "$2"; + elif [ `which wget` ]; then + wget -nv -O "$2" "$1" + fi +} + +if [[ $WP_VERSION =~ [0-9]+\.[0-9]+(\.[0-9]+)? ]]; then + WP_TESTS_TAG="tags/$WP_VERSION" +elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + WP_TESTS_TAG="trunk" +else + # http serves a single offer, whereas https serves multiple. we only want one + download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json + grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json + LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') + if [[ -z "$LATEST_VERSION" ]]; then + echo "Latest WordPress version could not be found" + exit 1 + fi + WP_TESTS_TAG="tags/$LATEST_VERSION" +fi + +set -ex + +install_wp() { + + if [ -d $WP_CORE_DIR ]; then + return; + fi + + mkdir -p $WP_CORE_DIR + + if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + mkdir -p /tmp/wordpress-nightly + download https://wordpress.org/nightly-builds/wordpress-latest.zip /tmp/wordpress-nightly/wordpress-nightly.zip + unzip -q /tmp/wordpress-nightly/wordpress-nightly.zip -d /tmp/wordpress-nightly/ + mv /tmp/wordpress-nightly/wordpress/* $WP_CORE_DIR + else + if [ $WP_VERSION == 'latest' ]; then + local ARCHIVE_NAME='latest' + else + local ARCHIVE_NAME="wordpress-$WP_VERSION" + fi + download https://wordpress.org/${ARCHIVE_NAME}.tar.gz /tmp/wordpress.tar.gz + tar --strip-components=1 -zxmf /tmp/wordpress.tar.gz -C $WP_CORE_DIR + fi + + download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php +} + +install_test_suite() { + # portable in-place argument for both GNU sed and Mac OSX sed + if [[ $(uname -s) == 'Darwin' ]]; then + local ioption='-i .bak' + else + local ioption='-i' + fi + + # set up testing suite if it doesn't yet exist + if [ ! -d $WP_TESTS_DIR ]; then + # set up testing suite + mkdir -p $WP_TESTS_DIR + svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes + svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data + fi + + if [ ! -f wp-tests-config.php ]; then + download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php + # remove all forward slashes in the end + WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") + sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php + fi + +} + +install_db() { + + if [ ${SKIP_DB_CREATE} = "true" ]; then + return 0 + fi + + # parse DB_HOST for port or socket references + local PARTS=(${DB_HOST//\:/ }) + local DB_HOSTNAME=${PARTS[0]}; + local DB_SOCK_OR_PORT=${PARTS[1]}; + local EXTRA="" + + if ! [ -z $DB_HOSTNAME ] ; then + if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then + EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" + elif ! [ -z $DB_SOCK_OR_PORT ] ; then + EXTRA=" --socket=$DB_SOCK_OR_PORT" + elif ! [ -z $DB_HOSTNAME ] ; then + EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" + fi + fi + + # create database + mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA +} + +install_wp +install_test_suite +install_db diff --git a/index.php b/index.php index 8b676e30fd869..bddebc69149df 100644 --- a/index.php +++ b/index.php @@ -27,6 +27,127 @@ function gutenberg_menu() { } add_action( 'admin_menu', 'gutenberg_menu' ); + +$wp_registered_blocks = array(); + +/** + * Registers a block. + * + * @param string $slug Block slug including namespace. + * @param array $settings Block settings. + + * @return array The block, if it has been successfully registered. + */ +function register_block( $slug, $settings ) { + global $wp_registered_blocks; + + if ( ! is_string( $slug ) ) { + $message = __( 'Block slugs must be strings.' ); + _doing_it_wrong( __FUNCTION__, $message, '0.1.0' ); + return false; + } + + $slug_matcher = '/^[a-z0-9-]+\/[a-z0-9-]+$/'; + if ( ! preg_match( $slug_matcher, $slug ) ) { + $message = __( 'Block slugs must contain a namespace prefix. Example: my-plugin/my-custom-block' ); + _doing_it_wrong( __FUNCTION__, $message, '0.1.0' ); + return false; + } + + if ( isset( $wp_registered_blocks[ $slug ] ) ) { + /* translators: 1: block slug */ + $message = sprintf( __( 'Block "%s" is already registered.' ), $slug ); + _doing_it_wrong( __FUNCTION__, $message, '0.1.0' ); + return false; + } + + $settings['slug'] = $slug; + $wp_registered_blocks[ $slug ] = $settings; + + return $settings; +} + +/** + * Unregisters a block. + * + * @param string $slug Block slug. + * @return array The previous block value, if it has been + * successfully unregistered; otherwise `null`. + */ +function unregister_block( $slug ) { + global $wp_registered_blocks; + if ( ! isset( $wp_registered_blocks[ $slug ] ) ) { + /* translators: 1: block slug */ + $message = sprintf( __( 'Block "%s" is not registered.' ), $slug ); + _doing_it_wrong( __FUNCTION__, $message, '0.1.0' ); + return false; + } + $unregistered_block = $wp_registered_blocks[ $slug ]; + unset( $wp_registered_blocks[ $slug ] ); + + return $unregistered_block; +} + +/** + * Extract the block attributes from the block's attributes string + * + * @since 0.1.0 + * + * @param string $attr_string Attributes string. + + * @return array + */ +function parse_block_attributes( $attr_string ) { + $attributes_matcher = '/([^\s]+):([^\s]+)\s*/'; + preg_match_all( $attributes_matcher, $attr_string, $matches ); + $attributes = array(); + foreach ( $matches[1] as $index => $attribute_match ) { + $attributes[ $attribute_match ] = $matches[2][ $index ]; + } + + return $attributes; +} + +/** + * Renders the dynamic blocks into the post content + * + * @since 0.1.0 + * + * @param string $content Post content. + * + * @return string Updated post content. + */ +function do_blocks( $content ) { + global $wp_registered_blocks; + + // Extract the blocks from the post content. + $open_matcher = '/).)*)-->.*?/'; + preg_match_all( $open_matcher, $content, $matches, PREG_OFFSET_CAPTURE ); + + $new_content = $content; + foreach ( $matches[0] as $index => $block_match ) { + $block_name = $matches[1][ $index ][0]; + // do nothing if the block is not registered. + if ( ! isset( $wp_registered_blocks[ $block_name ] ) ) { + continue; + } + + $block_markup = $block_match[0]; + $block_position = $block_match[1]; + $block_attributes_string = $matches[2][ $index ][0]; + $block_attributes = parse_block_attributes( $block_attributes_string ); + + // Call the block's render function to generate the dynamic output. + $output = call_user_func( $wp_registered_blocks[ $block_name ]['render'], $block_attributes ); + + // Replace the matched block with the dynamic output. + $new_content = str_replace( $block_markup, $output, $new_content ); + } + + return $new_content; +} +add_filter( 'the_content', 'do_blocks', 10 ); // BEFORE do_shortcode(). + /** * Registers common scripts to be used as dependencies of the editor and plugins. * @@ -59,9 +180,9 @@ function gutenberg_register_scripts() { * @since 0.1.0 */ function gutenberg_add_edit_links_filters() { - // For hierarchical post types + // For hierarchical post types. add_filter( 'page_row_actions', 'gutenberg_add_edit_links', 10, 2 ); - // For non-hierarchical post types + // For non-hierarchical post types. add_filter( 'post_row_actions', 'gutenberg_add_edit_links', 10, 2 ); } add_action( 'admin_init', 'gutenberg_add_edit_links_filters' ); @@ -71,14 +192,18 @@ function gutenberg_add_edit_links_filters() { * the Gutenberg editor. * * @since 0.1.0 + * + * @param array $actions Post actions. + * @param array $post Edited post. + * + * @return array Updated post actions. */ function gutenberg_add_edit_links( $actions, $post ) { $can_edit_post = current_user_can( 'edit_post', $post->ID ); $title = _draft_or_post_title( $post->ID ); if ( $can_edit_post && 'trash' !== $post->post_status ) { - // Build the Gutenberg edit action. See also: - // WP_Posts_List_Table::handle_row_actions() + // Build the Gutenberg edit action. See also: WP_Posts_List_Table::handle_row_actions(). $gutenberg_url = menu_page_url( 'gutenberg', false ); $gutenberg_action = sprintf( '%s', @@ -94,7 +219,9 @@ function gutenberg_add_edit_links( $actions, $post ) { $edit_offset = array_search( 'edit', array_keys( $actions ), true ); $actions = array_merge( array_slice( $actions, 0, $edit_offset + 1 ), - array( 'gutenberg hide-if-no-js' => $gutenberg_action ), + array( + 'gutenberg hide-if-no-js' => $gutenberg_action, + ), array_slice( $actions, $edit_offset + 1 ) ); } @@ -107,6 +234,8 @@ function gutenberg_add_edit_links( $actions, $post ) { * * @since 0.1.0 * + * @param string $domain Translation domain. + * * @return array */ function gutenberg_get_jed_locale_data( $domain ) { @@ -118,10 +247,10 @@ function gutenberg_get_jed_locale_data( $domain ) { $domain => array( '' => array( 'domain' => $domain, - 'lang' => is_admin() ? get_user_locale() : get_locale() - ) - ) - ) + 'lang' => is_admin() ? get_user_locale() : get_locale(), + ), + ), + ), ); if ( ! empty( $translations->headers['Plural-Forms'] ) ) { @@ -154,16 +283,16 @@ function gutenberg_scripts_and_styles( $hook ) { * Scripts */ - // The editor code itself + // The editor code itself. wp_enqueue_script( 'wp-editor', plugins_url( 'editor/build/index.js', __FILE__ ), array( 'wp-i18n', 'wp-blocks', 'wp-element' ), filemtime( plugin_dir_path( __FILE__ ) . 'editor/build/index.js' ), - true // $in_footer + true // enqueue in the footer. ); - // Load an actual post if an ID is specified + // Load an actual post if an ID is specified. $post_to_edit = null; if ( isset( $_GET['post_id'] ) && (int) $_GET['post_id'] > 0 ) { $request = new WP_REST_Request( @@ -190,7 +319,7 @@ function gutenberg_scripts_and_styles( $hook ) { ); } - // Prepare Jed locale data + // Prepare Jed locale data. $locale_data = gutenberg_get_jed_locale_data( 'gutenberg' ); wp_add_inline_script( 'wp-editor', @@ -198,7 +327,7 @@ function gutenberg_scripts_and_styles( $hook ) { 'before' ); - // Initialize the editor + // Initialize the editor. wp_add_inline_script( 'wp-editor', 'wp.editor.createEditorInstance( \'editor\', _wpGutenbergPost );' ); /** diff --git a/phpcs.ruleset.xml b/phpcs.ruleset.xml new file mode 100644 index 0000000000000..83f15faffd4e7 --- /dev/null +++ b/phpcs.ruleset.xml @@ -0,0 +1,12 @@ + + + Generally-applicable sniffs for WordPress plugins + + + + + + + phpunit/* + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000000000..2eb5f4eae3c4b --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + ./phpunit/ + + + diff --git a/phpunit/bootstrap.php b/phpunit/bootstrap.php new file mode 100644 index 0000000000000..60fa04f5af1c7 --- /dev/null +++ b/phpunit/bootstrap.php @@ -0,0 +1,35 @@ + array( + $this, + 'render_dummy_block', + ), + ); + register_block( 'core/dummy', $settings ); + $post_content = + 'before' . + '' . + 'between' . + '' . + 'after'; + + $updated_post_content = do_blocks( $post_content ); + $this->assertEquals( $updated_post_content, + 'before' . + 'b1' . + 'between' . + 'b2' . + 'after' + ); + } + + function test_dynamic_block_rendering_with_content() { + $settings = array( + 'render' => array( + $this, + 'render_dummy_block', + ), + ); + register_block( 'core/dummy', $settings ); + $post_content = + 'before' . + 'this should be ignored' . + 'between' . + 'this should also be ignored' . + 'after'; + + $updated_post_content = do_blocks( $post_content ); + $this->assertEquals( $updated_post_content, + 'before' . + 'b1' . + 'between' . + 'b2' . + 'after' + ); + } +} diff --git a/phpunit/class-registration-test.php b/phpunit/class-registration-test.php new file mode 100644 index 0000000000000..f99b06375ec3e --- /dev/null +++ b/phpunit/class-registration-test.php @@ -0,0 +1,103 @@ +assertFalse( $result ); + } + + /** + * Should reject blocks without a namespace + * + * @expectedIncorrectUsage register_block + */ + function test_invalid_slugs_without_namespace() { + $result = register_block( 'text', array() ); + $this->assertFalse( $result ); + } + + /** + * Should reject blocks with invalid characters + * + * @expectedIncorrectUsage register_block + */ + function test_invlalid_characters() { + $result = register_block( 'still/_doing_it_wrong', array() ); + $this->assertFalse( $result ); + } + + /** + * Should accept valid block names + */ + function test_register_block() { + global $wp_registered_blocks; + $settings = array( + 'icon' => 'text', + ); + $updated_settings = register_block( 'core/text', $settings ); + $this->assertEquals( $updated_settings, array( + 'icon' => 'text', + 'slug' => 'core/text', + ) ); + $this->assertEquals( $updated_settings, $wp_registered_blocks['core/text'] ); + } + + /** + * Should fail to re-register the same block + * + * @expectedIncorrectUsage register_block + */ + function test_register_block_twice() { + $settings = array( + 'icon' => 'text', + ); + $result = register_block( 'core/text', $settings ); + $this->assertNotFalse( $result ); + $result = register_block( 'core/text', $settings ); + $this->assertFalse( $result ); + } + + /** + * Unregistering should fail if a block is not registered + * + * @expectedIncorrectUsage unregister_block + */ + function test_unregister_not_registered_block() { + $result = unregister_block( 'core/unregistered' ); + $this->assertFalse( $result ); + } + + /** + * Should unregister existing blocks + */ + function test_unregister_block() { + global $wp_registered_blocks; + $settings = array( + 'icon' => 'text', + ); + register_block( 'core/text', $settings ); + $unregistered_block = unregister_block( 'core/text' ); + $this->assertEquals( $unregistered_block, array( + 'icon' => 'text', + 'slug' => 'core/text', + ) ); + $this->assertFalse( isset( $wp_registered_blocks['core/text'] ) ); + } +}