diff --git a/projects/packages/google-fonts-provider/.gitattributes b/projects/packages/google-fonts-provider/.gitattributes new file mode 100644 index 0000000000000..9f76af3f06e7e --- /dev/null +++ b/projects/packages/google-fonts-provider/.gitattributes @@ -0,0 +1,16 @@ +# Files not needed to be distributed in the package. +.gitattributes export-ignore +.github/ export-ignore +package.json export-ignore + +# Files to include in the mirror repo, but excluded via gitignore +# Remember to end all directories with `/**` to properly tag every file. +# /src/js/example.min.js production-include + +# Files to exclude from the mirror repo, but included in the monorepo. +# Remember to end all directories with `/**` to properly tag every file. +.gitignore production-exclude +changelog/** production-exclude +phpunit.xml.dist production-exclude +.phpcs.dir.xml production-exclude +tests/** production-exclude diff --git a/projects/packages/google-fonts-provider/.gitignore b/projects/packages/google-fonts-provider/.gitignore new file mode 100644 index 0000000000000..140fd587d2d52 --- /dev/null +++ b/projects/packages/google-fonts-provider/.gitignore @@ -0,0 +1,2 @@ +vendor/ +node_modules/ diff --git a/projects/packages/google-fonts-provider/.phpcs.dir.xml b/projects/packages/google-fonts-provider/.phpcs.dir.xml new file mode 100644 index 0000000000000..c76dbaee826cc --- /dev/null +++ b/projects/packages/google-fonts-provider/.phpcs.dir.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/packages/google-fonts-provider/CHANGELOG.md b/projects/packages/google-fonts-provider/CHANGELOG.md new file mode 100644 index 0000000000000..721294abd00ad --- /dev/null +++ b/projects/packages/google-fonts-provider/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + diff --git a/projects/packages/google-fonts-provider/README.md b/projects/packages/google-fonts-provider/README.md new file mode 100644 index 0000000000000..3a884b36f31ec --- /dev/null +++ b/projects/packages/google-fonts-provider/README.md @@ -0,0 +1,20 @@ +# google-fonts-provider + +WordPress Webfonts provider for Google Fonts + +## How to install google-fonts-provider + +### Installation From Git Repo + +## Contribute + +## Get Help + +## Security + +Need to report a security vulnerability? Go to [https://automattic.com/security/](https://automattic.com/security/) or directly to our security bug bounty site [https://hackerone.com/automattic](https://hackerone.com/automattic). + +## License + +google-fonts-provider is licensed under [GNU General Public License v2 (or later)](./LICENSE.txt) + diff --git a/projects/packages/google-fonts-provider/changelog/.gitkeep b/projects/packages/google-fonts-provider/changelog/.gitkeep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/projects/packages/google-fonts-provider/composer.json b/projects/packages/google-fonts-provider/composer.json new file mode 100644 index 0000000000000..2fcdc56532ebe --- /dev/null +++ b/projects/packages/google-fonts-provider/composer.json @@ -0,0 +1,44 @@ +{ + "name": "automattic/jetpack-google-fonts-provider", + "description": "WordPress Webfonts provider for Google Fonts", + "type": "library", + "license": "GPL-2.0-or-later", + "require": {}, + "require-dev": { + "yoast/phpunit-polyfills": "1.0.3", + "automattic/jetpack-changelogger": "^3.0" + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "phpunit": [ + "./vendor/phpunit/phpunit/phpunit --colors=always" + ], + "test-coverage": [ + "php -dpcov.directory=. ./vendor/bin/phpunit --coverage-clover \"$COVERAGE_DIR/clover.xml\"" + ], + "test-php": [ + "@composer phpunit" + ] + }, + "repositories": [ + { + "type": "path", + "url": "../../packages/*", + "options": { + "monorepo": true + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-master": "0.1.x-dev" + }, + "textdomain": "jetpack-google-fonts-provider" + } +} diff --git a/projects/packages/google-fonts-provider/package.json b/projects/packages/google-fonts-provider/package.json new file mode 100644 index 0000000000000..cdb9d61bb3c1e --- /dev/null +++ b/projects/packages/google-fonts-provider/package.json @@ -0,0 +1,29 @@ +{ + "private": true, + "name": "@automattic/jetpack-google-fonts-provider", + "version": "0.1.0-alpha", + "description": "WordPress Webfonts provider for Google Fonts", + "homepage": "https://jetpack.com", + "bugs": { + "url": "https://github.com/Automattic/jetpack/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/Automattic/jetpack.git" + }, + "license": "GPL-2.0-or-later", + "author": "Automattic", + "scripts": { + "build": "echo 'Not implemented.", + "build-js": "echo 'Not implemented.", + "build-production": "echo 'Not implemented.", + "build-production-js": "echo 'Not implemented.", + "clean": "true" + }, + "devDependencies": {}, + "engines": { + "node": "^14.18.3 || ^16.13.2", + "pnpm": "^6.23.6", + "yarn": "use pnpm instead - see docs/yarn-upgrade.md" + } +} diff --git a/projects/packages/google-fonts-provider/phpunit.xml.dist b/projects/packages/google-fonts-provider/phpunit.xml.dist new file mode 100644 index 0000000000000..3223c32458db2 --- /dev/null +++ b/projects/packages/google-fonts-provider/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + tests/php + + + + + + + src + + + diff --git a/projects/packages/google-fonts-provider/src/class-google-fonts-provider.php b/projects/packages/google-fonts-provider/src/class-google-fonts-provider.php new file mode 100644 index 0000000000000..04340986a9f67 --- /dev/null +++ b/projects/packages/google-fonts-provider/src/class-google-fonts-provider.php @@ -0,0 +1,343 @@ +get_remote_styles( $url, $args ); + + /* + * Early return if the request failed. + * Cache an empty string for 60 seconds to avoid bottlenecks. + */ + if ( empty( $css ) ) { + set_site_transient( $id, '', MINUTE_IN_SECONDS ); + return ''; + } + + // Cache the CSS for a month. + set_site_transient( $id, $css, MONTH_IN_SECONDS ); + } + + return $css; + } + + /** + * Gets styles from the remote font service via the given URL. + * + * @param string $url The URL to fetch. + * @param array $args Optional. The arguments to pass to `wp_remote_get()`. + * Default empty array. + * @return string The styles on success. Empty string on failure. + */ + protected function get_remote_styles( $url, array $args = array() ) { + // Use a modern user-agent, to get woff2 files. + $args['user-agent'] = 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:73.0) Gecko/20100101 Firefox/73.0'; + + // Get the remote URL contents. + $response = wp_safe_remote_get( $url, $args ); + + // Early return if the request failed. + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return ''; + } + + // Get the response body. + return wp_remote_retrieve_body( $response ); + } + + /** + * Gets the `@font-face` CSS styles for Google Fonts. + * + * This method does the following processing tasks: + * 1. Orchestrates an optimized Google Fonts API URL for each font-family. + * 2. Caches each URL, if not already cached. + * 3. Does a remote request to the Google Fonts API service to fetch the styles. + * 4. Generates the `@font-face` for all its webfonts. + * + * @return string The `@font-face` CSS. + */ + public function get_css() { + $css = ''; + $urls = $this->build_collection_api_urls(); + + foreach ( $urls as $url ) { + $css .= $this->get_cached_remote_styles( 'google_fonts_' . md5( $url ), $url ); + } + + return $css; + } + + /** + * Builds the Google Fonts URL for a collection of webfonts. + * + * For example, if given the following webfonts: + * ``` + * array( + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'normal', + * 'font-weight' => '200 400', + * ), + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'italic', + * 'font-weight' => '400 600', + * ), + * ) + * ``` + * then the returned collection would be: + * ``` + * array( + * 'https://fonts.googleapis.com/css2?family=Source+Serif+Pro:ital,wght@0,200;0,300;0,400;1,400;1,500;1,600&display=fallback' + * ) + * ``` + * + * @return array Collection of font-family urls. + */ + private function build_collection_api_urls() { + $font_families_urls = array(); + + /* + * Iterate over each font-family group to build the Google Fonts API URL + * for that specific family. Each is added to the collection of URLs to be + * returned to the `get_css()` method for making the remote request. + */ + foreach ( $this->organize_webfonts() as $font_display => $font_families ) { + $url_parts = array(); + foreach ( $font_families as $font_family => $webfonts ) { + list( $normal_weights, $italic_weights ) = $this->collect_font_weights( $webfonts ); + + // Build the font-style with its font-weights. + $url_part = rawurlencode( $font_family ); + if ( empty( $italic_weights ) && ! empty( $normal_weights ) ) { + $url_part .= ':wght@' . implode( ';', $normal_weights ); + } elseif ( ! empty( $italic_weights ) && empty( $normal_weights ) ) { + $url_part .= ':ital,wght@1,' . implode( ';', $normal_weights ); + } elseif ( ! empty( $italic_weights ) && ! empty( $normal_weights ) ) { + $url_part .= ':ital,wght@0,' . implode( ';0,', $normal_weights ) . ';1,' . implode( ';1,', $italic_weights ); + } + + // Add it to the collection. + $url_parts[] = $url_part; + } + + // Build the URL for this font-family and add it to the collection. + $font_families_urls[] = $this->root_url . '?family=' . implode( '&family=', $url_parts ) . '&display=' . $font_display; + } + + return $font_families_urls; + } + + /** + * Organizes the webfonts by font-display and then font-family. + * + * To optimizing building the URL for the Google Fonts API request, + * this method organizes the webfonts first by font-display and then + * by font-family. + * + * For example, if given the following webfonts: + * ``` + * array( + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'normal', + * 'font-weight' => '200 400', + * ), + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'italic', + * 'font-weight' => '400 600', + * ), + * ) + * ``` + * then the returned collection would be: + * ``` + * array( + * 'fallback' => array( + * 'Source Serif Pro' => array( + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'normal', + * 'font-weight' => '200 400', + * ), + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'italic', + * 'font-weight' => '400 600', + * ), + * ), + * ), + * ) + * + * @return array[][] Webfonts organized by font-display and then font-family. + */ + private function organize_webfonts() { + $font_display_groups = array(); + + /* + * Group by font-display. + * Each font-display will need to be a separate request. + */ + foreach ( $this->webfonts as $webfont ) { + if ( ! isset( $font_display_groups[ $webfont['font-display'] ] ) ) { + $font_display_groups[ $webfont['font-display'] ] = array(); + } + $font_display_groups[ $webfont['font-display'] ][] = $webfont; + } + + /* + * Iterate over each font-display group and group by font-family. + * Multiple font-families can be combined in the same request, + * but their params need to be grouped. + */ + foreach ( $font_display_groups as $font_display => $font_display_group ) { + $font_families = array(); + + foreach ( $font_display_group as $webfont ) { + if ( ! isset( $font_families[ $webfont['font-family'] ] ) ) { + $font_families[ $webfont['font-family'] ] = array(); + } + $font_families[ $webfont['font-family'] ][] = $webfont; + } + + $font_display_groups[ $font_display ] = $font_families; + } + + return $font_display_groups; + } + + /** + * Collects all font-weights grouped by 'normal' and 'italic' font-style. + * + * For example, if given the following webfonts: + * ``` + * array( + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'normal', + * 'font-weight' => '200 400', + * ), + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'italic', + * 'font-weight' => '400 600', + * ), + * ) + * ``` + * Then the returned collection would be: + * ``` + * array( + * array( 200, 300, 400 ), + * array( 400, 500, 600 ), + * ) + * ``` + * + * @param array $webfonts Webfonts to process. + * @return array[] { + * The font-weights grouped by font-style. + * + * @type array $normal_weights Individual font-weight values for 'normal' font-style. + * @type array $italic_weights Individual font-weight values for 'italic' font-style. + * } + */ + private function collect_font_weights( array $webfonts ) { + $normal_weights = array(); + $italic_weights = array(); + + foreach ( $webfonts as $webfont ) { + $font_weights = $this->get_font_weights( $webfont['font-weight'] ); + // Skip this webfont if it does not have a font-weight defined. + if ( empty( $font_weights ) ) { + continue; + } + + // Add the individual font-weights to the end of font-style array. + if ( 'italic' === $webfont['font-style'] ) { + array_push( $italic_weights, ...$font_weights ); + } else { + array_push( $normal_weights, ...$font_weights ); + } + } + + // Remove duplicates. + $normal_weights = array_unique( $normal_weights ); + $italic_weights = array_unique( $italic_weights ); + + return array( $normal_weights, $italic_weights ); + } + + /** + * Converts the given string of font-weight into an array of individual weight values. + * + * When given a single font-weight, the value is wrapped into an array. + * + * A range of font-weights is specified as '400 600' with the lightest value first, + * a space, and then the heaviest value last. + * + * When given a range of font-weight values, the range is converted into individual + * font-weight values. For example, a range of '400 600' is converted into + * `array( 400, 500, 600 )`. + * + * @param string $font_weights The font-weights string. + * @return array The font-weights array. + */ + private function get_font_weights( $font_weights ) { + $font_weights = trim( $font_weights ); + + // A single font-weight. + if ( false === strpos( $font_weights, ' ' ) ) { + return array( $font_weights ); + } + + // Process a range of font-weight values that are delimited by ' '. + $font_weights = explode( ' ', $font_weights ); + + // If there are 2 values, treat them as a range. + if ( 2 === count( $font_weights ) ) { + $font_weights = range( (int) $font_weights[0], (int) $font_weights[1], 100 ); + } + + return $font_weights; + } +} diff --git a/projects/packages/google-fonts-provider/tests/php/bootstrap.php b/projects/packages/google-fonts-provider/tests/php/bootstrap.php new file mode 100644 index 0000000000000..46763b04a2cdb --- /dev/null +++ b/projects/packages/google-fonts-provider/tests/php/bootstrap.php @@ -0,0 +1,11 @@ + $font_family, + 'provider' => 'google', + ), + ) + ); + } +} +add_action( 'after_setup_theme', 'jetpack_add_google_font_provider' );