You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2353 lines
68 KiB

<?php
/**
* Class AMP_Story_Post_Type
*
* @package AMP
*/
/**
* Class AMP_Story_Post_Type
*/
class AMP_Story_Post_Type {
/**
* The slug of the post type to store URLs that have AMP errors.
*
* @var string
*/
const POST_TYPE_SLUG = 'amp_story';
/**
* The option name where story settings are stored.
*/
const STORY_SETTINGS_OPTION = 'story_settings';
/**
* The meta prefix applied to story settings options saved as individual post meta.
*/
const STORY_SETTINGS_META_PREFIX = 'amp_story_';
/**
* Minimum required version of Gutenberg required.
*
* @var string
*/
const REQUIRED_GUTENBERG_VERSION = '6.6';
/**
* The slug of the story card CSS file.
*
* @var string
*/
const STORY_CARD_CSS_SLUG = 'amp-story-card';
/**
* The rewrite slug for this post type.
*
* @var string
*/
const REWRITE_SLUG = 'stories';
/**
* AMP Stories script handle.
*
* @var string
*/
const AMP_STORIES_SCRIPT_HANDLE = 'amp-stories-editor';
/**
* AMP Stories style handle.
*
* @var string
*/
const AMP_STORIES_STYLE_HANDLE = 'amp-stories';
/**
* AMP Stories editor style handle.
*
* @var string
*/
const AMP_STORIES_EDITOR_STYLE_HANDLE = 'amp-stories-editor';
/**
* AMP Stories Ajax action.
*
* @var string
*/
const AMP_STORIES_AJAX_ACTION = 'amp-story-export';
/**
* Story page inner width in the editor.
*
* @var number
*/
const STORY_PAGE_INNER_WIDTH = 328;
/**
* Story page inner height in the editor.
*
* @var number
*/
const STORY_PAGE_INNER_HEIGHT = 553;
/**
* Check if the required version of block capabilities available.
*
* Requires either Gutenberg 6.6+ or WordPress 5.3+ (which includes Gutenberg 6.6)
*
* @todo Eventually the Gutenberg requirement should be removed.
*
* @return bool Whether capabilities are available.
*/
public static function has_required_block_capabilities() {
return (
( defined( 'GUTENBERG_DEVELOPMENT_MODE' ) && GUTENBERG_DEVELOPMENT_MODE )
||
( defined( 'GUTENBERG_VERSION' ) && version_compare( GUTENBERG_VERSION, self::REQUIRED_GUTENBERG_VERSION, '>=' ) )
||
version_compare( get_bloginfo( 'version' ), '5.3-RC2', '>=' )
);
}
/**
* Registers the post type to store URLs with validation errors.
*
* @return void
*/
public static function register() {
if ( ! AMP_Options_Manager::is_stories_experience_enabled() || ! self::has_required_block_capabilities() ) {
return;
}
register_post_type(
self::POST_TYPE_SLUG,
[
'labels' => [
'name' => _x( 'Stories', 'post type general name', 'amp' ),
'singular_name' => _x( 'Story', 'post type singular name', 'amp' ),
'add_new' => _x( 'New', 'story', 'amp' ),
'add_new_item' => __( 'Add New Story', 'amp' ),
'edit_item' => __( 'Edit Story', 'amp' ),
'new_item' => __( 'New Story', 'amp' ),
'view_item' => __( 'View Story', 'amp' ),
'view_items' => __( 'View Stories', 'amp' ),
'search_items' => __( 'Search Stories', 'amp' ),
'not_found' => __( 'No stories found.', 'amp' ),
'not_found_in_trash' => __( 'No stories found in Trash.', 'amp' ),
'all_items' => __( 'All Stories', 'amp' ),
'archives' => __( 'Story Archives', 'amp' ),
'attributes' => __( 'Story Attributes', 'amp' ),
'insert_into_item' => __( 'Insert into story', 'amp' ),
'uploaded_to_this_item' => __( 'Uploaded to this story', 'amp' ),
'featured_image' => __( 'Featured Image', 'amp' ),
'set_featured_image' => __( 'Set featured image', 'amp' ),
'remove_featured_image' => __( 'Remove featured image', 'amp' ),
'use_featured_image' => __( 'Use as featured image', 'amp' ),
'filter_items_list' => __( 'Filter stories list', 'amp' ),
'items_list_navigation' => __( 'Stories list navigation', 'amp' ),
'items_list' => __( 'Stories list', 'amp' ),
'item_published' => __( 'Story published.', 'amp' ),
'item_published_privately' => __( 'Story published privately.', 'amp' ),
'item_reverted_to_draft' => __( 'Story reverted to draft.', 'amp' ),
'item_scheduled' => __( 'Story scheduled', 'amp' ),
'item_updated' => __( 'Story updated.', 'amp' ),
'menu_name' => _x( 'Stories', 'admin menu', 'amp' ),
'name_admin_bar' => _x( 'Story', 'add new on admin bar', 'amp' ),
],
'menu_icon' => 'dashicons-book',
'taxonomies' => [
'post_tag',
'category',
],
'supports' => [
'title', // Used for amp-story[title].
'author', // Used for the amp/amp-story-post-author block.
'editor',
'thumbnail', // Used for poster images.
'amp',
'revisions', // Without this, the REST API will return 404 for an autosave request.
'custom-fields', // Used for global stories settings.
],
'rewrite' => [
'slug' => self::REWRITE_SLUG,
],
'public' => true,
'show_ui' => true,
'show_in_rest' => true,
'template' => [
[
'amp/amp-story-page',
[],
[
[
'amp/amp-story-text',
[
'placeholder' => __( 'Write text…', 'amp' ),
],
],
],
],
],
]
);
add_filter( 'post_row_actions', [ __CLASS__, 'remove_classic_editor_link' ], 11, 2 );
add_filter( 'wp_kses_allowed_html', [ __CLASS__, 'filter_kses_allowed_html' ], 10, 2 );
add_filter( 'rest_request_before_callbacks', [ __CLASS__, 'filter_rest_request_for_kses' ], 100, 3 );
add_action( 'wp_default_styles', [ __CLASS__, 'register_story_card_styling' ] );
add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'enqueue_block_editor_styles' ] );
add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'enqueue_block_editor_scripts' ] );
add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'export_latest_stories_block_editor_data' ], 100 );
add_action( 'wp_enqueue_scripts', [ __CLASS__, 'add_custom_stories_styles' ] );
add_action(
'amp_story_head',
function() {
// Theme support for title-tag is implied for stories. See _wp_render_title_tag().
echo '<title>' . esc_html( wp_get_document_title() ) . '</title>' . "\n";
},
1
);
add_action( 'amp_story_head', 'wp_enqueue_scripts', 1 );
add_action(
'amp_story_head',
function() {
/*
* Same as wp_print_styles() but importantly omitting the wp_print_styles action, which themes/plugins
* can use to output arbitrary styling. Styling is constrained in story template via the
* \AMP_Story_Post_Type::filter_frontend_print_styles_array() method.
*/
wp_styles()->do_items();
},
8
);
add_action( 'amp_story_head', 'amp_add_generator_metadata' );
add_action( 'amp_story_head', 'rest_output_link_wp_head', 10, 0 );
add_action( 'amp_story_head', 'wp_resource_hints', 2 );
add_action( 'amp_story_head', 'feed_links', 2 );
add_action( 'amp_story_head', 'feed_links_extra', 3 );
add_action( 'amp_story_head', 'rsd_link' );
add_action( 'amp_story_head', 'wlwmanifest_link' );
add_action( 'amp_story_head', 'adjacent_posts_rel_link_wp_head', 10, 0 );
add_action( 'amp_story_head', 'noindex', 1 );
add_action( 'amp_story_head', 'wp_generator' );
add_action( 'amp_story_head', 'rel_canonical' );
add_action( 'amp_story_head', 'wp_shortlink_wp_head', 10, 0 );
add_action( 'amp_story_head', 'wp_site_icon', 99 );
add_action( 'amp_story_head', 'wp_oembed_add_discovery_links' );
// Disable admin bar from even trying to be output, since wp_head and wp_footer hooks are not on the template.
add_filter(
'show_admin_bar',
static function( $show ) {
if ( is_singular( self::POST_TYPE_SLUG ) ) {
$show = false;
}
return $show;
}
);
// Remove unnecessary settings.
add_filter( 'block_editor_settings', [ __CLASS__, 'filter_block_editor_settings' ], 10, 2 );
// Limit the styles that are printed in a story.
add_filter( 'print_styles_array', [ __CLASS__, 'filter_frontend_print_styles_array' ] );
add_filter( 'print_styles_array', [ __CLASS__, 'filter_editor_print_styles_array' ] );
// Select the single-amp_story.php template for AMP Stories.
add_filter( 'template_include', [ __CLASS__, 'filter_template_include' ] );
// Get an embed template for this post type.
add_filter( 'embed_template', [ __CLASS__, 'get_embed_template' ], 10, 3 );
// Enqueue the styling for the /embed endpoint.
add_action( 'embed_footer', [ __CLASS__, 'enqueue_embed_styling' ] );
// In the block editor, remove the title from above the AMP Stories embed.
add_filter( 'embed_html', [ __CLASS__, 'remove_title_from_embed' ], 10, 2 );
// Change some attributes for the AMP story embed.
add_filter( 'embed_html', [ __CLASS__, 'change_embed_iframe_attributes' ], 10, 2 );
// Override the render_callback for AMP story embeds.
add_filter( 'pre_render_block', [ __CLASS__, 'override_story_embed_callback' ], 10, 2 );
// The AJAX handler for exporting an AMP story.
add_action( 'wp_ajax_' . self::AMP_STORIES_AJAX_ACTION, [ __CLASS__, 'handle_export' ] );
// Register render callback for just-in-time inclusion of dependent Google Font styles.
add_filter( 'render_block', [ __CLASS__, 'render_block_with_google_fonts' ], 10, 2 );
// Wrap each movable inner block in amp-story-grid-layer.
add_filter( 'render_block', [ __CLASS__, 'render_block_with_grid_layer' ], 10, 2 );
add_filter( 'use_block_editor_for_post_type', [ __CLASS__, 'use_block_editor_for_story_post_type' ], PHP_INT_MAX, 2 );
add_filter( 'classic_editor_enabled_editors_for_post_type', [ __CLASS__, 'filter_enabled_editors_for_story_post_type' ], PHP_INT_MAX, 2 );
self::register_block_latest_stories();
self::register_block_page_attachment();
register_block_type(
'amp/amp-story-post-author',
[
'render_callback' => [ __CLASS__, 'render_post_author_block' ],
]
);
register_block_type(
'amp/amp-story-post-date',
[
'render_callback' => [ __CLASS__, 'render_post_date_block' ],
]
);
register_block_type(
'amp/amp-story-post-title',
[
'render_callback' => [ __CLASS__, 'render_post_title_block' ],
]
);
add_filter(
'amp_content_sanitizers',
static function( $sanitizers ) {
if ( is_singular( self::POST_TYPE_SLUG ) ) {
$sanitizers['AMP_Story_Sanitizer'] = [];
// Disable noscript fallbacks since not allowed in AMP Stories.
$sanitizers['AMP_Img_Sanitizer']['add_noscript_fallback'] = false;
$sanitizers['AMP_Audio_Sanitizer']['add_noscript_fallback'] = false;
$sanitizers['AMP_Video_Sanitizer']['add_noscript_fallback'] = false;
$sanitizers['AMP_Iframe_Sanitizer']['add_noscript_fallback'] = false; // Note that iframe is not yet allowed in an AMP Story.
}
return $sanitizers;
}
);
// Omit the core theme sanitizer for the story template.
add_filter(
'amp_content_sanitizers',
static function( $sanitizers ) {
if ( is_singular( self::POST_TYPE_SLUG ) ) {
unset( $sanitizers['AMP_Core_Theme_Sanitizer'] );
}
return $sanitizers;
}
);
add_filter(
'amp_content_sanitizers',
static function( $sanitizers ) {
if ( self::can_export() ) {
$post = get_queried_object();
$slug = sanitize_title( $post->post_title, $post->ID );
$sanitizers['AMP_Story_Export_Sanitizer'] = self::get_export_args( $slug );
$sanitizers['AMP_Style_Sanitizer']['include_manifest_comment'] = 'never';
}
return $sanitizers;
},
100 // Run sanitizer after the others (but before style sanitizer and validating sanitizer).
);
add_action( 'wp_head', [ __CLASS__, 'print_feed_link' ] );
// Register story settings meta.
$stories_settings_definitions = self::get_stories_settings_definitions();
foreach ( $stories_settings_definitions as $option_key => $definition ) {
$meta_args = isset( $definition['meta_args'] )
? (array) $definition['meta_args']
: [];
$meta_args_defaults = [
'type' => 'string',
'object_subtype' => self::POST_TYPE_SLUG,
'description' => '',
'single' => true,
'show_in_rest' => true,
];
register_meta(
'post',
self::STORY_SETTINGS_META_PREFIX . $option_key,
wp_parse_args( $meta_args, $meta_args_defaults )
);
}
add_action( 'wp_insert_post', [ __CLASS__, 'add_story_settings_meta_to_new_story' ], 10, 3 );
AMP_Story_Media::init();
}
/**
* Remove classic editor action from AMP Story listing.
*
* @param array $actions AMP Story row actions.
* @param WP_Post $post WP_Post object.
* @return array Actions.
*/
public static function remove_classic_editor_link( $actions, $post ) {
if ( 'amp_story' === $post->post_type ) {
unset( $actions['classic'] );
}
return $actions;
}
/**
* Filters an inline style attribute and removes disallowed rules.
*
* This is equivalent to the WordPress core function of the same name,
* except that this does not remove CSS with parentheses in it.
*
* Also, it adds a few more allowed attributes.
*
* @see safecss_filter_attr()
*
* @param string $css A string of CSS rules.
*
* @return string Filtered string of CSS rules.
*/
private static function safecss_filter_attr( $css ) {
$css = wp_kses_no_null( $css );
$css = str_replace( [ "\n", "\r", "\t" ], '', $css );
$allowed_protocols = wp_allowed_protocols();
$css_array = explode( ';', trim( $css ) );
/** This filter is documented in wp-includes/kses.php */
$allowed_attr = apply_filters(
'safe_style_css',
[
'background',
'background-color',
'background-image',
'background-position',
'border',
'border-width',
'border-color',
'border-style',
'border-right',
'border-right-color',
'border-right-style',
'border-right-width',
'border-bottom',
'border-bottom-color',
'border-bottom-style',
'border-bottom-width',
'border-left',
'border-left-color',
'border-left-style',
'border-left-width',
'border-top',
'border-top-color',
'border-top-style',
'border-top-width',
'border-spacing',
'border-collapse',
'caption-side',
'color',
'font',
'font-family',
'font-size',
'font-style',
'font-variant',
'font-weight',
'letter-spacing',
'line-height',
'text-align',
'text-decoration',
'text-indent',
'text-transform',
'height',
'min-height',
'max-height',
'width',
'min-width',
'max-width',
'margin',
'margin-right',
'margin-bottom',
'margin-left',
'margin-top',
'padding',
'padding-right',
'padding-bottom',
'padding-left',
'padding-top',
'flex',
'flex-grow',
'flex-shrink',
'flex-basis',
'clear',
'cursor',
'direction',
'float',
'overflow',
'vertical-align',
'list-style-type',
'grid-template-columns',
]
);
// Add some more allowed attributes.
$allowed_attr[] = 'display';
$allowed_attr[] = 'opacity';
$allowed_attr[] = 'object-position';
$allowed_attr[] = 'position';
$allowed_attr[] = 'top';
$allowed_attr[] = 'left';
$allowed_attr[] = 'transform';
/*
* CSS attributes that accept URL data types.
*
* This is in accordance to the CSS spec and unrelated to
* the sub-set of supported attributes above.
*
* See: https://developer.mozilla.org/en-US/docs/Web/CSS/url
*/
$css_url_data_types = [
'background',
'background-image',
'cursor',
'list-style',
'list-style-image',
];
if ( empty( $allowed_attr ) ) {
return $css;
}
$css = '';
foreach ( $css_array as $css_item ) {
if ( '' === $css_item ) {
continue;
}
$css_item = trim( $css_item );
$css_test_string = $css_item;
$found = false;
$url_attr = false;
if ( strpos( $css_item, ':' ) === false ) {
$found = true;
} else {
$parts = explode( ':', $css_item, 2 );
$css_selector = trim( $parts[0] );
if ( in_array( $css_selector, $allowed_attr, true ) ) {
$found = true;
$url_attr = in_array( $css_selector, $css_url_data_types, true );
}
}
if ( $found && $url_attr ) {
// Simplified: matches the sequence `url(*)`.
preg_match_all( '/url\([^)]+\)/', $parts[1], $url_matches );
foreach ( $url_matches[0] as $url_match ) {
// Clean up the URL from each of the matches above.
preg_match( '/^url\(\s*([\'\"]?)(.*)(\g1)\s*\)$/', $url_match, $url_pieces );
if ( empty( $url_pieces[2] ) ) {
$found = false;
break;
}
$url = trim( $url_pieces[2] );
if ( empty( $url ) || wp_kses_bad_protocol( $url, $allowed_protocols ) !== $url ) {
$found = false;
break;
} else {
// Remove the whole `url(*)` bit that was matched above from the CSS.
$css_test_string = str_replace( $url_match, '', $css_test_string );
}
}
}
if ( $found ) {
if ( '' !== $css ) {
$css .= ';';
}
$css .= $css_item;
}
}
return $css;
}
/**
* Filters the response before executing any REST API callbacks.
*
* Temporarily modifies post content during saving in a way that KSES
* does not strip actually valid CSS from post content, making block content invalid.
*
* @todo Remove once core has better CSS parsing.
*
* @link https://core.trac.wordpress.org/ticket/37134
*
* @param WP_HTTP_Response|WP_Error $response Result to send to the client. Usually a WP_REST_Response or WP_Error.
* @param array $handler Route handler used for the request.
* @param WP_REST_Request $request Request used to generate the response.
*
* @return WP_HTTP_Response|WP_Error The filtered response.
*/
public static function filter_rest_request_for_kses( $response, $handler, $request ) {
// Short-circuit since this is relevant only for users without unfiltered_html capability.
if ( current_user_can( 'unfiltered_html' ) ) {
return $response;
}
if ( is_wp_error( $response ) ) {
return $response;
}
$obj = get_post_type_object( self::POST_TYPE_SLUG );
$slug = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name;
$editable_request_methods = array_map( 'trim', explode( ',', WP_REST_Server::EDITABLE ) );
if ( ! in_array( $request->get_method(), $editable_request_methods, true ) || ! preg_match( "#^/wp/v2/{$slug}/#s", $request->get_route() ) ) {
return $response;
}
if ( ! current_user_can( 'edit_post', $request['id'] ) ) {
return $response;
}
$style_attr_values = [];
// Replace inline styles with temporary data-temp-style-hash attribute before KSES...
add_filter(
'content_save_pre',
static function ( $post_content ) use ( &$style_attr_values ) {
$post_content = preg_replace_callback(
'|(?P<before><\w+(?:-\w+)*\s[^>]*?)style=\\\"(?P<styles>[^"]*)\\\"(?P<after>([^>]+?)*>)|', // Extra slashes appear here because $post_content is pre-slashed..
static function ( $matches ) use ( &$style_attr_values ) {
$hash = md5( $matches['styles'] );
$style_attr_values[ $hash ] = self::safecss_filter_attr( wp_unslash( $matches['styles'] ) );
// Replaces the complete style attribute value with its hashed version.
return $matches['before'] . sprintf( ' data-temp-style-hash="%s" ', $hash ) . $matches['after'];
},
$post_content
);
return $post_content;
},
0
);
// ...And bring it back afterwards.
add_filter(
'content_save_pre',
static function ( $post_content ) use ( &$style_attr_values ) {
// Replaces hashed style attribute value with the original value again.
return preg_replace_callback(
'/ data-temp-style-hash=\\\"(?P<hash>[0-9a-f]+)\\\"/',
static function ( $matches ) use ( $style_attr_values ) {
return isset( $style_attr_values[ $matches['hash'] ] ) ? sprintf( ' style="%s"', esc_attr( wp_slash( $style_attr_values[ $matches['hash'] ] ) ) ) : '';
},
$post_content
);
},
20
);
return $response;
}
/**
* Filter the allowed tags for KSES to allow for amp-story children.
*
* @param array $allowed_tags Allowed tags.
* @return array Allowed tags.
*/
public static function filter_kses_allowed_html( $allowed_tags ) {
$story_components = [
'amp-story-page',
'amp-story-grid-layer',
'amp-story-cta-layer',
'amp-img',
'amp-video',
'img',
];
foreach ( $story_components as $story_component ) {
$attributes = array_fill_keys( array_keys( AMP_Allowed_Tags_Generated::get_allowed_attributes() ), true );
$rule_specs = AMP_Allowed_Tags_Generated::get_allowed_tag( $story_component );
foreach ( $rule_specs as $rule_spec ) {
$attributes = array_merge( $attributes, array_fill_keys( array_keys( $rule_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ] ), true ) );
}
$allowed_tags[ $story_component ] = $attributes;
}
// @todo This perhaps should not be allowed if user does not have capability.
foreach ( $allowed_tags as &$allowed_tag ) {
$allowed_tag['animate-in'] = true;
$allowed_tag['animate-in-duration'] = true;
$allowed_tag['animate-in-delay'] = true;
$allowed_tag['animate-in-after'] = true;
$allowed_tag['data-font-family'] = true;
$allowed_tag['data-block-name'] = true;
$allowed_tag['data-temp-style-hash'] = true;
$allowed_tag['layout'] = true;
$allowed_tag['object-position'] = true;
}
return $allowed_tags;
}
/**
* Filter which styles will be used in the edit page of an AMP Story.
*
* @param array $handles Style handles.
* @return array Styles to print.
*/
public static function filter_editor_print_styles_array( $handles ) {
if (
! function_exists( 'get_current_screen' ) ||
! get_current_screen() ||
self::POST_TYPE_SLUG !== get_current_screen()->post_type ||
! get_current_screen()->is_block_editor
) {
return $handles;
}
return array_filter(
$handles,
static function( $handle ) {
if ( ! isset( wp_styles()->registered[ $handle ] ) ) {
return false;
}
$dep = wp_styles()->registered[ $handle ];
// If we have amp-stories as dependency, allow the style.
if ( is_array( $dep->deps ) && in_array( self::AMP_STORIES_STYLE_HANDLE, $dep->deps, true ) ) {
return true;
}
// Disable the active theme's style.
if ( self::is_theme_stylesheet( $dep->src ) ) {
return false;
}
return true;
}
);
}
/**
* Filter which styles will be printed on an AMP Story.
*
* @param array $handles Style handles.
* @return array Styles to print.
*/
public static function filter_frontend_print_styles_array( $handles ) {
if ( ! is_singular( self::POST_TYPE_SLUG ) || is_embed() ) {
return $handles;
}
return array_filter(
$handles,
static function( $handle ) {
if ( ! isset( wp_styles()->registered[ $handle ] ) ) {
return false;
}
$dep = wp_styles()->registered[ $handle ];
if ( 'fonts.googleapis.com' === wp_parse_url( $dep->src, PHP_URL_HOST ) ) {
return true;
}
if ( 'wp-block-library' === $handle || self::AMP_STORIES_STYLE_HANDLE === $handle ) {
return true;
}
if ( in_array( self::AMP_STORIES_STYLE_HANDLE, $dep->deps, true ) ) {
return true;
}
return false;
}
);
}
/**
* Enqueue the styles for the block editor.
*/
public static function enqueue_block_editor_styles() {
if ( self::POST_TYPE_SLUG !== get_current_screen()->post_type ) {
return;
}
wp_enqueue_style(
self::AMP_STORIES_EDITOR_STYLE_HANDLE,
amp_get_asset_url( 'css/amp-stories-editor-compiled.css' ),
[ 'wp-edit-blocks', 'amp-stories' ],
AMP__VERSION
);
wp_styles()->add_data( self::AMP_STORIES_EDITOR_STYLE_HANDLE, 'rtl', 'replace' );
// Include all fonts in the editor since new fonts can be selected at runtime.
// In a frontend context, the fonts are added only as needed via \AMP_Story_Post_Type::render_block_with_google_fonts().
$fonts = self::get_fonts();
foreach ( $fonts as $font ) {
wp_add_inline_style(
self::AMP_STORIES_EDITOR_STYLE_HANDLE,
self::get_inline_font_style_rule( $font )
);
}
self::enqueue_general_styles();
}
/**
* Enqueue styles that are needed for frontend and editor both.
*/
public static function enqueue_general_styles() {
// This CSS is separate since it's used both in front-end and in the editor.
wp_enqueue_style(
self::AMP_STORIES_STYLE_HANDLE,
amp_get_asset_url( 'css/amp-stories.css' ),
[],
AMP__VERSION
);
wp_styles()->add_data( self::AMP_STORIES_STYLE_HANDLE, 'rtl', 'replace' );
}
/**
* Filters the settings to pass to the block editor.
*
* Removes support for custom color palettes for AMP stories.
* Removes custom theme stylesheets for editing AMP Stories.
*
* @param array $editor_settings Default editor settings.
* @param WP_Post $post Post being edited.
*
* @return array Modified editor settings.
*/
public static function filter_block_editor_settings( $editor_settings, $post ) {
if ( self::POST_TYPE_SLUG !== get_current_screen()->post_type ) {
return $editor_settings;
}
unset( $editor_settings['fontSizes'], $editor_settings['colors'] );
if ( isset( $editor_settings['styles'] ) ) {
foreach ( $editor_settings['styles'] as $key => $style ) {
// If the baseURL is not set or if the URL doesn't include theme styles, move to next.
if ( ! isset( $style['baseURL'] ) || ! self::is_theme_stylesheet( $style['baseURL'] ) ) {
continue;
}
/**
* Filters the editor style to allow whitelisting it for AMP Stories editor.
*
* @param bool $whitelisted If to whitelist the stylesheet.
* @param string $base_url The URL for the stylesheet.
*/
if ( false === apply_filters( 'amp_stories_whitelist_editor_style', false, $style['baseURL'] ) ) {
unset( $editor_settings['styles'][ $key ] );
}
}
}
$editor_settings['codeEditingEnabled'] = false;
$editor_settings['richEditingEnabled'] = true;
return $editor_settings;
}
/**
* Checks if a stylesheet is from the theme or parent theme.
*
* @param string $url Stylesheet URL.
* @return bool If the stylesheet comes from the theme.
*/
public static function is_theme_stylesheet( $url ) {
return (
0 === strpos( $url, get_stylesheet_directory_uri() )
||
0 === strpos( $url, get_template_directory_uri() )
);
}
/**
* Registers the story card styling.
*
* This can't take place on the 'wp_enqueue_scripts' hook, as the /embed endpoint doesn't trigger that.
*
* @param WP_Styles $wp_styles The styles.
*/
public static function register_story_card_styling( WP_Styles $wp_styles ) {
// Register the styling for the /embed endpoint and the Latest Stories block.
$wp_styles->add(
self::STORY_CARD_CSS_SLUG,
amp_get_asset_url( '/css/' . self::STORY_CARD_CSS_SLUG . '.css' ),
[],
AMP__VERSION
);
}
/**
* Export data used for Latest Stories block.
*/
public static function export_latest_stories_block_editor_data() {
if ( self::POST_TYPE_SLUG === get_current_screen()->post_type ) {
return;
}
$url = add_query_arg(
'ver',
wp_styles()->registered[ self::STORY_CARD_CSS_SLUG ]->ver,
wp_styles()->registered[ self::STORY_CARD_CSS_SLUG ]->src
);
/** This filter is documented in wp-includes/class.wp-styles.php */
$url = apply_filters( 'style_loader_src', $url, self::STORY_CARD_CSS_SLUG );
wp_add_inline_script(
AMP_Post_Meta_Box::BLOCK_ASSET_HANDLE,
sprintf(
'var ampLatestStoriesBlockData = %s;',
wp_json_encode(
[
'storyCardStyleURL' => $url,
]
)
),
'before'
);
}
/**
* Enqueue scripts for the block editor.
*/
public static function enqueue_block_editor_scripts() {
$screen = get_current_screen();
if ( ! $screen instanceof \WP_Screen ) {
return;
}
if ( self::POST_TYPE_SLUG !== $screen->post_type ) {
return;
}
$asset_file = AMP__DIR__ . '/assets/js/' . self::AMP_STORIES_SCRIPT_HANDLE . '.asset.php';
$asset = require $asset_file;
$dependencies = $asset['dependencies'];
$version = $asset['version'];
wp_enqueue_script(
self::AMP_STORIES_SCRIPT_HANDLE,
amp_get_asset_url( 'js/' . self::AMP_STORIES_SCRIPT_HANDLE . '.js' ),
$dependencies,
$version,
false
);
if ( function_exists( 'wp_set_script_translations' ) ) {
wp_set_script_translations( 'amp-editor-story-blocks-build', 'amp' );
} elseif ( function_exists( 'wp_get_jed_locale_data' ) || function_exists( 'gutenberg_get_jed_locale_data' ) ) {
$locale_data = function_exists( 'wp_get_jed_locale_data' ) ? wp_get_jed_locale_data( 'amp-editor-story-blocks-build' ) : gutenberg_get_jed_locale_data( 'amp-editor-story-blocks-build' );
$translations = wp_json_encode( $locale_data );
wp_add_inline_script(
self::AMP_STORIES_SCRIPT_HANDLE,
'wp.i18n.setLocaleData( ' . $translations . ', "amp" );',
'before'
);
}
/**
* Filter list of allowed video mime types.
*
* This can be used to add additionally supported formats, for example by plugins
* that do video transcoding.
*
* @since 1.3
*
* @param array Allowed video mime types.
*/
$allowed_video_mime_types = apply_filters( 'amp_story_allowed_video_types', [ 'video/mp4' ] );
// If `$allowed_video_mime_types` doesn't have valid data or is empty add default supported type.
if ( ! is_array( $allowed_video_mime_types ) || empty( $allowed_video_mime_types ) ) {
$allowed_video_mime_types = [ 'video/mp4' ];
}
// Only add currently supported mime types.
$allowed_video_mime_types = array_values( array_intersect( $allowed_video_mime_types, wp_get_mime_types() ) );
// Convert auto advancement.
$meta_definitions = self::get_stories_settings_definitions();
$auto_advancement_options = $meta_definitions['auto_advance_after']['data']['options'];
/**
* Filters the list of allowed post types for use in page attachments.
*
* @since 1.3
*
* @param array Allowed post types.
*/
$page_attachment_post_types = apply_filters( 'amp_story_allowed_page_attachment_post_types', [ 'page', 'post' ] );
$post_types = [];
foreach ( $page_attachment_post_types as $post_type ) {
$post_type_object = get_post_type_object( $post_type );
if ( $post_type_object ) {
$post_types[ $post_type ] = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name;
}
}
wp_localize_script(
self::AMP_STORIES_SCRIPT_HANDLE,
'ampStoriesEditorSettings',
[
'allowedVideoMimeTypes' => $allowed_video_mime_types,
'allowedPageAttachmentPostTypes' => $post_types,
'storySettings' => [
'autoAdvanceAfterOptions' => $auto_advancement_options,
],
]
);
wp_localize_script(
self::AMP_STORIES_SCRIPT_HANDLE,
'ampStoriesFonts',
self::get_fonts()
);
wp_localize_script(
self::AMP_STORIES_SCRIPT_HANDLE,
'ampStoriesExport',
[
'action' => self::AMP_STORIES_AJAX_ACTION,
'nonce' => wp_create_nonce( self::AMP_STORIES_AJAX_ACTION ),
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
]
);
}
/**
* Get inline font style rule.
*
* @param array $font Font.
* @return string Font style rule.
*/
public static function get_inline_font_style_rule( $font ) {
$families = array_map(
'wp_json_encode',
array_merge( (array) $font['name'], $font['fallbacks'] )
);
return sprintf(
'[data-font-family="%s"] { font-family: %s; }',
$font['name'],
implode( ', ', $families )
);
}
/**
* Set template for amp_story post type.
*
* @param string $template Template.
* @return string Template.
*/
public static function filter_template_include( $template ) {
if ( is_singular( self::POST_TYPE_SLUG ) && ! is_embed() ) {
$template = AMP__DIR__ . '/includes/templates/single-amp_story.php';
}
return $template;
}
/**
* Add CSS to AMP Stories' frontend.
*
* @see /assets/css/amp-stories.css
*/
public static function add_custom_stories_styles() {
if ( ! is_singular( self::POST_TYPE_SLUG ) ) {
return;
}
wp_enqueue_style(
'amp-stories-frontend',
amp_get_asset_url( 'css/amp-stories-frontend.css' ),
[ self::AMP_STORIES_STYLE_HANDLE ],
AMP__VERSION,
false
);
wp_styles()->add_data( 'amp-stories-frontend', 'rtl', 'replace' );
wp_styles()->add_data( self::STORY_CARD_CSS_SLUG, 'rtl', 'replace' );
// Also enqueue this since it's possible to embed another story into a story.
wp_enqueue_style( self::STORY_CARD_CSS_SLUG );
self::enqueue_general_styles();
}
/**
* Get list of fonts used in AMP Stories.
*
* @return array Fonts.
*/
public static function get_fonts() {
static $fonts = null;
if ( isset( $fonts ) ) {
return $fonts;
}
// Default system fonts.
$fonts = [
[
'name' => 'Arial',
'fallbacks' => [ 'Helvetica Neue', 'Helvetica', 'sans-serif' ],
],
[
'name' => 'Arial Black',
'fallbacks' => [ 'Arial Black', 'Arial Bold', 'Gadget', 'sans-serif' ],
],
[
'name' => 'Arial Narrow',
'fallbacks' => [ 'Arial', 'sans-serif' ],
],
[
'name' => 'Baskerville',
'fallbacks' => [ 'Baskerville Old Face', 'Hoefler Text', 'Garamond', 'Times New Roman', 'serif' ],
],
[
'name' => 'Brush Script MT',
'fallbacks' => [ 'cursive' ],
],
[
'name' => 'Copperplate',
'fallbacks' => [ 'Copperplate Gothic Light', 'fantasy' ],
],
[
'name' => 'Courier New',
'fallbacks' => [ 'Courier', 'Lucida Sans Typewriter', 'Lucida Typewriter', 'monospace' ],
],
[
'name' => 'Century Gothic',
'fallbacks' => [ 'CenturyGothic', 'AppleGothic', 'sans-serif' ],
],
[
'name' => 'Garamond',
'fallbacks' => [ 'Baskerville', 'Baskerville Old Face', 'Hoefler Text', 'Times New Roman', 'serif' ],
],
[
'name' => 'Georgia',
'fallbacks' => [ 'Times', 'Times New Roman', 'serif' ],
],
[
'name' => 'Gill Sans',
'fallbacks' => [ 'Gill Sans MT', 'Calibri', 'sans-serif' ],
],
[
'name' => 'Lucida Bright',
'fallbacks' => [ 'Georgia', 'serif' ],
],
[
'name' => 'Lucida Sans Typewriter',
'fallbacks' => [ 'Lucida Console', 'monaco', 'Bitstream Vera Sans Mono', 'monospace' ],
],
[
'name' => 'Palatino',
'fallbacks' => [ 'Palatino Linotype', 'Palatino LT STD', 'Book Antiqua', 'Georgia', 'serif' ],
],
[
'name' => 'Papyrus',
'fallbacks' => [ 'fantasy' ],
],
[
'name' => 'Tahoma',
'fallbacks' => [ 'Verdana', 'Segoe', 'sans-serif' ],
],
[
'name' => 'Times New Roman',
'fallbacks' => [ 'Times New Roman', 'Times', 'Baskerville', 'Georgia', 'serif' ],
],
[
'name' => 'Trebuchet MS',
'fallbacks' => [ 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', 'Tahoma', 'sans-serif' ],
],
[
'name' => 'Verdana',
'fallbacks' => [ 'Geneva', 'sans-serif' ],
],
];
$file = __DIR__ . '/data/fonts.json';
$fonts = array_merge( $fonts, self::get_google_fonts( $file ) );
$columns = wp_list_pluck( $fonts, 'name' );
array_multisort( $columns, SORT_ASC, $fonts );
$fonts_url = 'https://fonts.googleapis.com/css';
$subsets = [ 'latin', 'latin-ext' ];
/*
* Translators: To add an additional character subset specific to your language,
* translate this to 'greek', 'cyrillic', 'devanagari' or 'vietnamese'. Do not translate into your own language.
*/
$subset = _x( 'no-subset', 'Add new subset (greek, cyrillic, devanagari, vietnamese)', 'amp' );
if ( 'cyrillic' === $subset ) {
$subsets[] = 'cyrillic';
$subsets[] = 'cyrillic-ext';
} elseif ( 'greek' === $subset ) {
$subsets[] = 'greek';
$subsets[] = 'greek-ext';
} elseif ( 'devanagari' === $subset ) {
$subsets[] = 'devanagari';
} elseif ( 'vietnamese' === $subset ) {
$subsets[] = 'vietnamese';
}
$fonts = array_map(
static function ( $font ) use ( $fonts_url, $subsets ) {
$font['slug'] = sanitize_title( $font['name'] );
if ( ! empty( $font['gfont'] ) ) {
$font['handle'] = sprintf( '%s-font', $font['slug'] );
$font['src'] = add_query_arg(
[
'family' => rawurlencode( $font['gfont'] ),
'subset' => rawurlencode( implode( ',', $subsets ) ),
'display' => 'swap',
],
$fonts_url
);
}
return $font;
},
$fonts
);
return $fonts;
}
/**
* Get list of Google Fonts from a given JSON file.
*
* @param string $file Path to file containing Google Fonts definitions.
*
* @return array $fonts Fonts list.
*/
public static function get_google_fonts( $file ) {
if ( ! is_readable( $file ) ) {
return [];
}
$file_content = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$google_fonts = json_decode( $file_content, true );
if ( empty( $google_fonts ) ) {
return [];
}
$fonts = [];
foreach ( $google_fonts as $font ) {
$variants = array_intersect(
$font['variants'],
[
'regular',
'italic',
'700',
'700italic',
]
);
$variants = array_map(
static function ( $variant ) {
$variant = str_replace(
[ '0italic', 'regular', 'italic' ],
[ '0i', '400', '400i' ],
$variant
);
return $variant;
},
$variants
);
$gfont = '';
if ( $variants ) {
$gfont = $font['family'] . ':' . implode( ',', $variants );
}
$fonts[] = [
'name' => $font['family'],
'fallbacks' => (array) self::get_font_fallback( $font['category'] ),
'gfont' => $gfont,
];
}
return $fonts;
}
/**
* Helper method to lookup fallback font.
*
* @param string $category Google font category.
*
* @return string $fallback Fallback font.
*/
public static function get_font_fallback( $category ) {
switch ( $category ) {
case 'sans-serif':
return 'sans-serif';
case 'handwriting':
case 'display':
return 'cursive';
case 'monospace':
return 'monospace';
default:
return 'serif';
}
}
/**
* Get a font.
*
* @param string $name Font family name.
* @return array|null The font or null if not defined.
*/
public static function get_font( $name ) {
$fonts = array_filter(
self::get_fonts(),
static function ( $font ) use ( $name ) {
return $font['name'] === $name;
}
);
return array_shift( $fonts );
}
/**
* Renders the amp/amp-story-post-author block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @return string Block content.
*/
public static function render_post_author_block( $attributes, $content ) {
return str_replace( '{content}', get_the_author(), $content );
}
/**
* Renders the amp/amp-story-post-date block.
*
* @todo Consider allowing to change the date format in the block settings.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @return string Block content.
*/
public static function render_post_date_block( $attributes, $content ) {
return str_replace( '{content}', get_the_date(), $content );
}
/**
* Renders the amp/amp-story-post-title block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @return string Block content.
*/
public static function render_post_title_block( $attributes, $content ) {
return str_replace( '{content}', get_the_title(), $content );
}
/**
* Include any required Google Font styles when rendering a block in AMP Stories.
*
* @see AMP_Story_Post_Type::enqueue_block_editor_styles() Where fonts are added in the story editor.
*
* @param string $block_content The block content about to be appended.
* @param array $block The full block, including name and attributes.
* @return string Block content.
*/
public static function render_block_with_google_fonts( $block_content, $block ) {
$font_family_attribute = 'ampFontFamily';
if ( empty( $block['attrs'][ $font_family_attribute ] ) ) {
return $block_content;
}
$font = self::get_font( $block['attrs'][ $font_family_attribute ] );
if ( ! $font ) {
return $block_content;
}
// Create style rule for the custom font. The style sanitizer will de-duplicate.
$style = sprintf(
'<style data-font-family="%s">%s</style>',
esc_attr( $font['name'] ),
self::get_inline_font_style_rule( $font )
);
// Make sure that the Google Font is enqueued.
if ( isset( $font['src'], $font['handle'] ) && ! wp_style_is( $font['handle'] ) ) {
wp_enqueue_style( $font['handle'], $font['src'], [], null, 'all' ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
}
return $style . $block_content;
}
/**
* Converts pixel to percentage based on the editor page sizes.
*
* @param string $axis Axis: x or y.
* @param number $pixels Pixel value.
*
* @return int|string Percentage value compared to AMP Story editor page.
*/
public static function get_percentage_from_pixels( $axis, $pixels ) {
if ( 'x' === $axis ) {
return number_format( ( ( $pixels / self::STORY_PAGE_INNER_WIDTH ) * 100 ), 2 );
} elseif ( 'y' === $axis ) {
return number_format( ( ( $pixels / self::STORY_PAGE_INNER_HEIGHT ) * 100 ), 2 );
}
return 0;
}
/**
* Get default height by block name.
*
* @param string $block Block name.
*
* @return int Height in pixels.
*/
protected static function get_blocks_default_height( $block ) {
switch ( $block ) {
case 'core/quote':
case 'core/video':
case 'core/embed':
return 200;
case 'core/pullquote':
return 250;
case 'core/table':
return 100;
case 'amp/amp-story-post-author':
case 'amp/amp-story-post-date':
return 50;
case 'amp/amp-story-post-title':
return 100;
default:
return 60;
}
}
/**
* Wraps each movable block into amp-story-grid-layer and animation wrapper with necessary attributes.
*
* @param string $block_content The block content about to be appended.
* @param array $block The full block, including name and attributes.
*
* @return string Modified content.
*/
public static function render_block_with_grid_layer( $block_content, $block ) {
$post = get_post();
if ( ! $post || self::POST_TYPE_SLUG !== $post->post_type ) {
return $block_content;
}
// If the block content already includes amp-story-grid-layer, stop.
if ( false !== strpos( $block_content, 'amp-story-grid-layer' ) ) {
return $block_content;
}
$movable_blocks = [
'core/code',
'core/embed',
'core/image',
'core/list',
'core/preformatted',
'core/pullquote',
'core/quote',
'core/table',
'core/verse',
'core/video',
'amp/amp-story-text',
'amp/amp-story-post-author',
'amp/amp-story-post-date',
'amp/amp-story-post-title',
'core/html',
'core/block', // Reusable blocks.
'core/template', // Reusable blocks.
];
$name = $block['blockName'];
// If the block is not movable, it doesn't need the wrapper.
if ( ! in_array( $name, $movable_blocks, true ) ) {
return $block_content;
}
$atts = $block['attrs'];
$wrapper_atts = [];
$style = [
'position' => 'absolute',
];
// Set default values if missing.
$width = isset( $atts['width'] ) ? $atts['width'] : 250;
$height = isset( $atts['height'] ) ? $atts['height'] : self::get_blocks_default_height( $name );
// Set passed attributes or default values (0, 5) for top and left.
$style['top'] = ! isset( $atts['positionTop'] ) ? '0%' : $atts['positionTop'] . '%';
$style['left'] = ! isset( $atts['positionLeft'] ) ? '5%' : $atts['positionLeft'] . '%';
$style['width'] = self::get_percentage_from_pixels( 'x', $width ) . '%';
$style['height'] = self::get_percentage_from_pixels( 'y', $height ) . '%';
$wrapper_style = isset( $atts['style'] ) ? $atts['style'] : '';
foreach ( $style as $att => $value ) {
$wrapper_style .= "$att:$value;";
}
if ( ! empty( $wrapper_style ) ) {
$wrapper_atts['style'] = $wrapper_style;
}
if ( ! empty( $atts['ampAnimationType'] ) ) {
$wrapper_atts['animate-in'] = $atts['ampAnimationType'];
if ( ! empty( $atts['ampAnimationDelay'] ) ) {
$wrapper_atts['animate-in-delay'] = $atts['ampAnimationDelay'];
}
if ( ! empty( $atts['ampAnimationDuration'] ) ) {
$wrapper_atts['animate-in-duration'] = $atts['ampAnimationDuration'];
}
if ( ! empty( $atts['ampAnimationAfter'] ) ) {
$wrapper_atts['animate-in-after'] = $atts['ampAnimationAfter'];
}
}
if ( isset( $atts['anchor'] ) ) {
$wrapper_atts['id'] = $atts['anchor'];
}
$before = '<amp-story-grid-layer template="vertical"><div class="amp-story-block-wrapper"';
foreach ( $wrapper_atts as $att => $value ) {
$before .= ' ' . $att . '="' . esc_attr( $value ) . '"';
}
$before .= '>';
$after = '</div></amp-story-grid-layer>';
return $before . $block_content . $after;
}
/**
* Filters whether a post is able to be edited in the block editor.
*
* Forces the block editor to be used for stories.
*
* @param bool $use_block_editor Whether the post type can be edited or not.
* @param string $post_type The current post type.
*
* @return bool Whether to use the block editor for the given post type.
*/
public static function use_block_editor_for_story_post_type( $use_block_editor, $post_type ) {
if ( self::POST_TYPE_SLUG === $post_type ) {
return true;
}
return $use_block_editor;
}
/**
* Filters the editors that are enabled for the given post type.
*
* Forces the block editor to be used for stories.
*
* @param array $editors Associative array of the editors and whether they are enabled for the post type.
* @param string $post_type The post type.
*
* @return array Filtered list of enabled editors.
*/
public static function filter_enabled_editors_for_story_post_type( $editors, $post_type ) {
if ( self::POST_TYPE_SLUG === $post_type ) {
$editors['classic_editor'] = false;
}
return $editors;
}
/**
* Get the AMP story's embed template.
*
* This is used when an AMP story is embedded in a post,
* often with the WordPress (embed) block.
*
* @param string $template The path of the template, from locate_template().
* @param string $type The file name.
* @param array $templates An array of possible templates.
* @return string $template The path of the template, from locate_template().
*/
public static function get_embed_template( $template, $type, $templates ) {
$old_amp_story_template = sprintf( 'embed-%s.php', self::POST_TYPE_SLUG );
if ( 'embed' === $type && in_array( $old_amp_story_template, $templates, true ) ) {
$template = AMP__DIR__ . '/includes/templates/embed-amp-story.php';
}
return $template;
}
/**
* Outputs a card of a single AMP story.
*
* Used for a slide in the Latest Stories block.
* The 'disable_link' parameter can prevent a link from appearing in the block editor.
* So on clicking the story card, it does not redirect to the story's URL.
*
* @param array $args {
* The arguments to create a single story card.
*
* @type WP_Post|int post The post object or ID in which to search for the featured image.
* @type string size The size of the image.
* @type bool disable_link Whether to disable the link in the card container.
* }
*/
public static function the_single_story_card( $args ) {
$args = wp_parse_args(
$args,
[
'post' => null,
'size' => 'full',
'disable_link' => false,
]
);
$post = get_post( $args['post'] );
if ( ! $post ) {
return;
}
$thumbnail_id = get_post_thumbnail_id( $post );
if ( ! $thumbnail_id || ! is_object( $post ) ) {
return;
}
$author_id = $post->post_author;
$author_display_name = get_the_author_meta( 'display_name', $author_id );
$wrapper_tag_name = $args['disable_link'] ? 'div' : 'a';
$avatar = get_avatar(
$author_id,
24,
'',
'',
[
'class' => 'latest-stories__avatar',
]
);
if ( ! $args['disable_link'] ) {
$href = sprintf(
'href="%s"',
esc_url( get_permalink( $post ) )
);
}
?>
<<?php echo esc_attr( $wrapper_tag_name ); ?> <?php echo isset( $href ) ? wp_kses_post( $href ) : ''; ?> class="latest_stories__link">
<?php
$url = wp_get_attachment_image_url( $thumbnail_id, $args['size'] );
printf(
'<img src="%s" width="%d" height="%d" alt="%s" class="latest-stories__featured-img" data-amp-layout="fixed">',
esc_url( $url ),
esc_attr( AMP_Story_Media::STORY_SMALL_IMAGE_DIMENSION / 2 ),
esc_attr( AMP_Story_Media::STORY_LARGE_IMAGE_DIMENSION / 2 ),
esc_attr( get_the_title( $post ) )
);
?>
<span class="latest-stories__title"><?php echo esc_html( get_the_title( $post ) ); ?></span>
<div class="latest-stories__meta">
<?php echo wp_kses_post( $avatar ); ?>
<span class="latest-stories__author">
<?php
printf(
/* translators: 1: the post author. 2: the amount of time ago. */
esc_html__( '%1$s &#8226; %2$s ago', 'amp' ),
esc_html( $author_display_name ),
esc_html( human_time_diff( get_post_time( 'U', false, $post ), current_time( 'timestamp' ) ) )
);
?>
</span>
</div>
</<?php echo esc_attr( $wrapper_tag_name ); ?>>
<?php
}
/**
* Enqueues this post type's stylesheet for the embed endpoint and Latest Stories block.
*/
public static function enqueue_embed_styling() {
if ( is_embed() && is_singular( self::POST_TYPE_SLUG ) ) {
wp_enqueue_style( self::STORY_CARD_CSS_SLUG );
}
}
/**
* Overrides the render_callback of an AMP story post embed, when using the WordPress (embed) block.
*
* WordPress post embeds are usually wrapped in an <iframe>,
* which can cause validation and display issues in AMP.
* This overrides the embed callback in that case, replacing the <iframe> with the simple AMP story card.
*
* @param string $pre_render The pre-rendered markup, default null.
* @param array $block The block to render.
* @return string|null $rendered_markup The rendered markup, or null to not override the existing render_callback.
*/
public static function override_story_embed_callback( $pre_render, $block ) {
if ( ! isset( $block['attrs']['url'], $block['blockName'] ) || ! in_array( $block['blockName'], [ 'core-embed/wordpress', 'core/embed' ], true ) ) {
return $pre_render;
}
// Taken from url_to_postid(), ensures that the URL is from this site.
$url = $block['attrs']['url'];
$url_host = wp_parse_url( $url, PHP_URL_HOST );
$home_url_host = wp_parse_url( home_url(), PHP_URL_HOST );
// Exit if the URL isn't from this site.
if ( $url_host !== $home_url_host ) {
return $pre_render;
}
$embed_url_path = wp_parse_url( $url, PHP_URL_PATH );
$base_url_path = wp_parse_url( trailingslashit( home_url( self::REWRITE_SLUG ) ), PHP_URL_PATH );
if ( 0 !== strpos( $embed_url_path, $base_url_path ) ) {
return $pre_render;
}
$path = substr( $embed_url_path, strlen( $base_url_path ) );
$post = get_post( get_page_by_path( $path, OBJECT, self::POST_TYPE_SLUG ) );
if ( self::POST_TYPE_SLUG !== get_post_type( $post ) ) {
return $pre_render;
}
wp_enqueue_style( self::STORY_CARD_CSS_SLUG );
ob_start();
?>
<div class="amp-story-embed">
<?php
self::the_single_story_card(
[
'post' => $post,
'size' => AMP_Story_Media::STORY_CARD_IMAGE_SIZE,
]
);
?>
</div>
<?php
return ob_get_clean();
}
/**
* Registers the dynamic block Latest Stories.
* Much of this is taken from the Core block Latest Posts.
*
* @see register_block_core_latest_posts()
*/
public static function register_block_latest_stories() {
register_block_type(
'amp/amp-latest-stories',
[
'attributes' => [
'className' => [
'type' => 'string',
],
'storiesToShow' => [
'type' => 'number',
'default' => 5,
],
'order' => [
'type' => 'string',
'default' => 'desc',
],
'orderBy' => [
'type' => 'string',
'default' => 'date',
],
'useCarousel' => [
'type' => 'boolean',
'default' => ! is_admin(),
],
],
'render_callback' => [ __CLASS__, 'render_block_latest_stories' ],
]
);
}
/**
* Renders the dynamic block Latest Stories.
* Much of this is taken from the Core block Latest Posts.
*
* @see render_block_core_latest_posts()
* @param array $attributes The block attributes.
* @return string $markup The rendered block markup.
*/
public static function render_block_latest_stories( $attributes ) {
$is_amp_carousel = ! empty( $attributes['useCarousel'] );
$args = [
'post_type' => self::POST_TYPE_SLUG,
'posts_per_page' => $attributes['storiesToShow'],
'post_status' => 'publish',
'order' => $attributes['order'],
'orderby' => $attributes['orderBy'],
'suppress_filters' => false,
'meta_key' => '_thumbnail_id',
];
$story_query = new WP_Query( $args );
$class = 'amp-block-latest-stories';
if ( isset( $attributes['className'] ) ) {
$class .= ' ' . $attributes['className'];
}
$size = AMP_Story_Media::STORY_CARD_IMAGE_SIZE;
$meta_height = 76;
$min_height = AMP_Story_Media::STORY_LARGE_IMAGE_DIMENSION / 2 + $meta_height;
ob_start();
?>
<div class="<?php echo esc_attr( $class ); ?>">
<?php if ( $is_amp_carousel ) : ?>
<amp-carousel layout="fixed-height" height="<?php echo esc_attr( $min_height ); ?>" type="carousel" class="latest-stories-carousel">
<?php else : ?>
<ul class="latest-stories-carousel">
<?php endif; ?>
<?php foreach ( $story_query->posts as $post ) : ?>
<<?php echo $is_amp_carousel ? 'div' : 'li'; ?> class="slide latest-stories__slide">
<?php
self::the_single_story_card(
[
'post' => $post,
'size' => $size,
'disable_link' => ! $is_amp_carousel,
]
);
?>
</<?php echo $is_amp_carousel ? 'div' : 'li'; ?>>
<?php
endforeach;
?>
</<?php echo $is_amp_carousel ? 'amp-carousel' : 'ul'; ?>>
</div>
<?php
wp_enqueue_style( self::STORY_CARD_CSS_SLUG );
if ( $is_amp_carousel ) {
wp_enqueue_script( 'amp-carousel' );
}
return ob_get_clean();
}
/**
* Registers the Page Attachment block.
*/
public static function register_block_page_attachment() {
register_block_type(
'amp/amp-story-page-attachment',
[
'attributes' => [
'postId' => [
'type' => 'number',
],
'title' => [
'type' => 'string',
'default' => '',
],
'openText' => [
'type' => 'string',
'default' => __( 'Swipe up', 'amp' ),
],
'theme' => [
'type' => 'string',
'default' => 'light',
],
'wrapperStyle' => [
'type' => 'object',
'default' => [],
],
'attachmentClass' => [
'type' => 'string',
'default' => 'amp-page-attachment-content',
],
],
'render_callback' => [ __CLASS__, 'render_block_page_attachment' ],
]
);
}
/**
* Renders the dynamic block Page Attachment.
*
* @param array $attributes The block attributes.
* @return string $markup The rendered block markup.
*/
public static function render_block_page_attachment( $attributes ) {
global $post;
if ( empty( $attributes['postId'] ) ) {
return null;
}
$content_post = get_post( absint( $attributes['postId'] ) );
if ( empty( $content_post ) ) {
return null;
}
$original_post = $post;
$post = $content_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
// Remove filter for not adding grid layer to blocks within the attachment content.
remove_filter( 'render_block', [ __CLASS__, 'render_block_with_grid_layer' ], 10 );
setup_postdata( $content_post );
$style = '';
if ( isset( $attributes['wrapperStyle']['backgroundColor'] ) ) {
$style .= 'background-color:' . $attributes['wrapperStyle']['backgroundColor'] . ';';
}
if ( isset( $attributes['wrapperStyle']['color'] ) ) {
$style .= 'color:' . $attributes['wrapperStyle']['color'] . ';';
}
ob_start();
?>
<amp-story-page-attachment layout="nodisplay" theme="light" data-cta-text="<?php echo esc_attr( $attributes['openText'] ); ?>" data-title="<?php echo esc_attr( $attributes['title'] ); ?>">
<div class="<?php echo esc_attr( $attributes['attachmentClass'] ); ?>" style="<?php echo esc_attr( $style ); ?>">
<h2><?php the_title(); ?></h2>
<div class="amp-page-attachment-inner-content">
<?php the_content(); ?>
</div>
</div>
</amp-story-page-attachment>
<?php
wp_reset_postdata();
// Add filter back.
add_filter( 'render_block', [ __CLASS__, 'render_block_with_grid_layer' ], 10, 2 );
$output = ob_get_clean();
$post = $original_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
return $output;
}
/**
* Add RSS feed link for stories.
*
* @since 1.2
*/
public static function print_feed_link() {
$post_type_object = get_post_type_object( self::POST_TYPE_SLUG );
$feed_url = add_query_arg(
'post_type',
self::POST_TYPE_SLUG,
get_feed_link()
);
printf(
'<link rel="alternate" type="%s" title="%s" href="%s">',
esc_attr( feed_content_type() ),
esc_attr( $post_type_object->labels->name ),
esc_url( $feed_url )
);
}
/**
* For amp_story embeds, removes the title from above the <iframe>.
*
* @param string $output The output to filter.
* @param WP_Post $post The post for the embed.
* @return string $output The filtered output.
*/
public static function remove_title_from_embed( $output, $post ) {
if ( self::POST_TYPE_SLUG !== get_post_type( $post ) ) {
return $output;
}
return preg_replace( '/<blockquote class="wp-embedded-content">.*?<\/blockquote>/', '', $output );
}
/**
* Changes the height of the AMP Story embed <iframe>.
*
* In the block editor, this embed typically appears in an <iframe>, though on the front-end it's not in an <iframe>.
* The height of the <iframe> isn't enough to display the full story, so this increases it.
*
* @param string $output The embed output.
* @param WP_Post $post The post for the embed.
* @return string The filtered embed output.
*/
public static function change_embed_iframe_attributes( $output, $post ) {
if ( self::POST_TYPE_SLUG !== get_post_type( $post ) ) {
return $output;
}
// Add 4px more height, as the <iframe> needs that to display the full image.
$new_height = (string) ( ( AMP_Story_Media::STORY_LARGE_IMAGE_DIMENSION / 2 ) + 4 );
return preg_replace(
'/(<iframe sandbox="allow-scripts"[^>]*\sheight=")(\w+)("[^>]*>)/',
sprintf( '${1}%s${3}', $new_height ),
$output
);
}
/**
* Checks for `story_export` and valid Ajax nonce.
*
* @return bool
*/
public static function can_export() {
return is_singular( self::POST_TYPE_SLUG ) && isset( $_GET['story_export'] ) && check_ajax_referer( self::AMP_STORIES_AJAX_ACTION, 'nonce', false );
}
/**
* Get the args used during a story export.
*
* @param string $slug The slug used to build the new `canonical_url`.
*
* @return array
*/
public static function get_export_args( $slug = '' ) {
$base_url = untrailingslashit( AMP_Options_Manager::get_option( 'story_export_base_url' ) );
return [
'base_url' => esc_url( $base_url ),
'canonical_url' => ( $base_url && $slug ) ? esc_url( trailingslashit( $base_url ) . $slug ) : false,
];
}
/**
* Returns an asset basename where the date directory structure is retained to avoid filename collisions.
*
* This means that an asset like `https://sample.org/wp-content/uploads/2019/07/sample.jpg`
* returns the basename `2019-07-sample.jpg` instead of `sample.jpg`.
*
* @param string $asset The URL of the export asset.
*
* @return string
*/
public static function export_image_basename( $asset ) {
$asset = preg_replace_callback(
'/uploads\/(.*)/',
static function( $matches ) {
return str_replace( '/', '-', $matches[1] );
},
$asset
);
return basename( $asset );
}
/**
* Ajax handler to export the story ZIP archive.
*
* This method returns an error as JSON and the binary data on success.
*/
public static function handle_export() {
check_ajax_referer( self::AMP_STORIES_AJAX_ACTION, 'nonce' );
// Get the post ID.
$post_id = isset( $_POST['post_ID'] ) ? absint( wp_unslash( $_POST['post_ID'] ) ) : 0;
// The user must have the correct permissions.
if ( ! current_user_can( 'publish_post', $post_id ) ) {
wp_send_json_error(
[
'errorMessage' => esc_html__( 'You do not have the required permissions to export stories.', 'amp' ),
],
403
);
}
// We need the ZipArchive class to make this work.
if ( ! class_exists( 'ZipArchive', false ) ) {
wp_send_json_error(
[
/* translators: %s is the ZipArchive class name. */
'errorMessage' => sprintf( esc_html__( 'The %s class is required to export stories.', 'amp' ), 'ZipArchive' ),
],
400
);
}
// Bail if the user has not saved the story yet.
if ( 'auto-draft' === get_post_status( $post_id ) ) {
wp_send_json_error(
[
'errorMessage' => esc_html__( 'Save the story before exporting.', 'amp' ),
],
401
);
}
// Generate and export the archive.
$export = self::generate_export( $post_id );
// Export failed.
if ( is_wp_error( $export ) ) {
$error_data = $export->get_error_data();
if ( is_array( $error_data ) && isset( $error_data['status'] ) ) {
$status = $error_data['status'];
} else {
$status = 500;
}
wp_send_json_error(
[
'errorMessage' => $export->get_error_message(),
],
$status
);
}
// Failed to export for an unknown reason not related to generating the archive.
wp_send_json_error(
[
'errorMessage' => esc_html__( 'Could not generate the story archive.', 'amp' ),
],
500
);
}
/**
* Generates a Zip archive from the AMP Story.
*
* @param int $post_id The post ID of the AMP Story.
* @return WP_Error
*/
private static function generate_export( $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error( 'amp_story_export_invalid_post', esc_html__( 'The story does not exist.', 'amp' ) );
}
$slug = sanitize_title( $post->post_title, $post->ID );
$file = wp_tempnam( $slug );
$zip = new ZipArchive();
$res = $zip->open( $file, ZipArchive::CREATE | ZipArchive::OVERWRITE );
if ( true !== $res ) {
/* translators: %s is the ZipArchive error code. */
return new WP_Error( 'amp_story_zip_archive_error', sprintf( esc_html__( 'There was an error generating the ZIP archive. Error code: %s', 'amp' ), $res ) );
}
// Passed to `get_preview_post_link()` for nonce access and to sanitize the output.
$query_args = [
'story_export' => true,
'_wpnonce' => wp_create_nonce( self::AMP_STORIES_AJAX_ACTION ),
];
// Passed to `wp_remote_get()`.
$args = [
'cookies' => wp_unslash( $_COOKIE ), // Pass along cookies so private pages and drafts can be accessed.
'timeout' => 20, // Increase from default of 5 to give extra time for the plugin to process story for exporting.
'sslverify' => false,
'redirection' => 0, // Because we're in a loop for redirection.
'headers' => [
'Cache-Control' => 'no-cache',
],
];
// Get the preview URL.
$response = wp_remote_get( get_preview_post_link( $post, $query_args ), $args );
// Ensure we have the required data.
if ( ! ( is_array( $response ) && isset( $response['body'] ) ) ) {
return new WP_Error( 'amp_story_export_response', esc_html__( 'Could not retrieve story HTML.', 'amp' ) );
}
// Get the HTML from the response body.
$html = $response['body'];
$assets = [];
$regex = '<!--\s*AMP_EXPORT_ASSETS\s*:\s*(\[.*?\])\s*-->';
// Get the assets from the AMP_EXPORT_ASSETS comment.
if ( preg_match( '#</body>.*?' . $regex . '#s', $html, $matches ) ) {
$assets = json_decode( $matches[1], true );
// Remove the comment.
$html = preg_replace( '/' . $regex . '/s', '', $html );
}
// Create the zip directory.
$zip->addEmptyDir( $slug );
// Add README.txt file.
$zip->addFromString(
$slug . '/README.txt',
file_get_contents( AMP__DIR__ . '/includes/story-export/readme.txt' ) // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
);
// Add index.html file.
$zip->addFromString( $slug . '/index.html', $html );
// Add the assets.
if ( ! empty( $assets ) ) {
// Create the empty assets directory.
$zip->addEmptyDir( $slug . '/assets' );
foreach ( $assets as $asset ) {
$response = wp_remote_get( $asset, [ 'sslverify' => false ] );
if ( is_array( $response ) && ! empty( $response['body'] ) ) {
$zip->addFromString( $slug . '/assets/' . self::export_image_basename( $asset ), $response['body'] );
}
}
}
// Close the active archive.
$zip->close();
// Read the file.
$fo = @fopen( $file, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen, WordPress.PHP.NoSilencedErrors.Discouraged
if ( ! $fo ) {
return new WP_Error( 'amp_story_export_file_open', esc_html__( 'Could not open the generated ZIP archive.', 'amp' ) );
}
header( 'Content-type: application/octet-stream' );
header( 'Content-Disposition: attachment; filename="' . sprintf( '%s.zip', $slug ) . '"' );
header( 'Content-length: ' . filesize( $file ) );
fpassthru( $fo );
unlink( $file );
die();
}
/**
* Returns the definitions for the stories settings.
*
* @since 1.3
*
* @return array
*
* - meta_args array Arguments passed to `register_meta`; sanitize_callback is required.
* - data array Any additional data.
*/
public static function get_stories_settings_definitions() {
return [
'auto_advance_after' => [
'meta_args' => [
'type' => 'string',
'sanitize_callback' => function( $value ) {
$valid_values = [ '', 'auto', 'time', 'media' ];
if ( ! in_array( $value, $valid_values, true ) ) {
return '';
}
return $value;
},
],
'data' => [
'options' => [
[
'value' => '',
'label' => __( 'Manual', 'amp' ),
'description' => '',
],
[
'value' => 'auto',
'label' => __( 'Automatic', 'amp' ),
'description' => __( 'Based on the duration of all animated blocks on the page', 'amp' ),
],
[
'value' => 'time',
'label' => __( 'After a certain time', 'amp' ),
'description' => '',
],
[
'value' => 'media',
'label' => __( 'After media has played', 'amp' ),
'description' => __( 'Based on the first media block encountered on the page', 'amp' ),
],
],
],
],
'auto_advance_after_duration' => [
'meta_args' => [
'type' => 'integer',
'sanitize_callback' => function( $value ) {
$value = intval( $value );
return filter_var(
$value,
FILTER_VALIDATE_INT,
[
'default' => 0,
'min_range' => 1,
'max_range' => 100,
]
);
},
],
'data' => [],
],
];
}
/**
* Adds stories global settings as post meta to all new Stories.
*
* @param int $post_id New Story post ID.
* @param \WP_Post $post Story post object.
* @param bool $update Whether this is an update or a new post being created.
*
* @return void
*/
public static function add_story_settings_meta_to_new_story( $post_id, $post, $update ) {
$is_story = ( self::POST_TYPE_SLUG === $post->post_type );
if ( $update || ! $is_story ) {
return;
}
$meta_definitions = self::get_stories_settings_definitions();
$story_settings = AMP_Options_Manager::get_option( self::STORY_SETTINGS_OPTION );
foreach ( $story_settings as $option_key => $value ) {
$sanitized_value = call_user_func( $meta_definitions[ $option_key ]['meta_args']['sanitize_callback'], $value );
add_post_meta( $post_id, self::STORY_SETTINGS_META_PREFIX . $option_key, $sanitized_value, true );
}
}
}