diff --git a/assets/src/js/godam-player/frontend.js b/assets/src/js/godam-player/frontend.js index 66eaa25e9..0d2056f59 100644 --- a/assets/src/js/godam-player/frontend.js +++ b/assets/src/js/godam-player/frontend.js @@ -24,8 +24,7 @@ import { findVideoById, getLightboxRoot, isLightboxVideo, - parseLightboxHash, - parseStartTime, + resolveDeepLinkTarget, } from './utils/lightboxTargets.js'; import { seekPlayer } from './utils/seekPlayer.js'; @@ -61,15 +60,18 @@ const initGodamPlayers = () => { * * The ID is resolved as a job ID first and an attachment ID second — see * `findVideoById()`. + * + * A `?t=` with no `#godam-video-{id}` hash (for example a key-moment link + * whose fragment was dropped) seeks the first ordinary GoDAM video on the + * page instead. */ const applyDeepLink = () => { - const targetId = parseLightboxHash( window.location.hash ); - if ( ! targetId ) { + const target = resolveDeepLinkTarget( window.location ); + if ( ! target ) { return; } - const searchParams = new URLSearchParams( window.location.search ); - const startTime = parseStartTime( searchParams.get( 't' ) ); + const { targetId, startTime, fromHash } = target; // Match on either ID, so a link built from an attachment ID works too. const isTarget = ( videoElement ) => @@ -84,6 +86,12 @@ const applyDeepLink = () => { return; } + // Without a hash, only an inline player is seeked; a lightbox copy of the + // same video is left closed. + if ( ! fromHash && isLightboxVideo( videoElement ) ) { + return; + } + // This is our player – remove the listener so it only runs once. document.removeEventListener( 'godamPlayerReady', onPlayerReady ); diff --git a/assets/src/js/godam-player/utils/lightboxTargets.js b/assets/src/js/godam-player/utils/lightboxTargets.js index 33762566d..2bc54209c 100644 --- a/assets/src/js/godam-player/utils/lightboxTargets.js +++ b/assets/src/js/godam-player/utils/lightboxTargets.js @@ -127,6 +127,52 @@ export function findVideoById( id, doc = document ) { return null; } +/** + * Find the first ordinary (non-lightbox) GoDAM video on the page. + * + * Used when a URL carries `?t=` but no `#godam-video-{id}` hash, so the start + * time still applies to the page's main video. Lightbox players are skipped: + * their inline render is only a poster, and opening an overlay for a link + * that never named a video would be surprising. + * + * @param {Document} doc - Document to search (defaults to `document`). + * @return {HTMLElement|null} The video element, or null when there is none. + */ +export function findFirstInlineVideo( doc = document ) { + const videos = doc.querySelectorAll( 'video[data-id], video[data-job_id]' ); + return [ ...videos ].find( ( video ) => ! isLightboxVideo( video ) ) || null; +} + +/** + * Work out which video a page URL deep-links to, and where it should start. + * + * `#godam-video-{id}` names the video. Without it, a `?t=` start time + * (for example a key-moment link whose fragment was dropped) applies to the + * first ordinary GoDAM video on the page; see {@link findFirstInlineVideo}. + * + * @param {Object} location - `{ search, hash }`, e.g. `window.location`. + * @param {Document} doc - Document to search (defaults to `document`). + * @return {Object|null} `{ targetId, startTime, fromHash }`, or null when the URL doesn't deep-link to a video. + */ +export function resolveDeepLinkTarget( location, doc = document ) { + const searchParams = new URLSearchParams( location?.search || '' ); + const startTime = parseStartTime( searchParams.get( 't' ) ); + const hashId = parseLightboxHash( location?.hash || '' ); + + if ( hashId ) { + return { targetId: hashId, startTime, fromHash: true }; + } + + if ( startTime === null ) { + return null; + } + + const firstVideo = findFirstInlineVideo( doc ); + const targetId = getLightboxId( firstVideo ); + + return targetId ? { targetId, startTime, fromHash: false } : null; +} + /** * Get the element to move into the lightbox for a given video. * diff --git a/assets/src/js/godam-player/utils/lightboxTargets.test.js b/assets/src/js/godam-player/utils/lightboxTargets.test.js index c126357bf..8008dc642 100644 --- a/assets/src/js/godam-player/utils/lightboxTargets.test.js +++ b/assets/src/js/godam-player/utils/lightboxTargets.test.js @@ -4,12 +4,14 @@ import { buildEmbedUrl, buildLightboxHash, + findFirstInlineVideo, findVideoById, getLightboxId, getLightboxRoot, isLightboxVideo, parseLightboxHash, parseStartTime, + resolveDeepLinkTarget, } from './lightboxTargets'; describe( 'parseLightboxHash', () => { @@ -292,3 +294,72 @@ describe( 'isLightboxVideo', () => { expect( isLightboxVideo( undefined ) ).toBe( false ); } ); } ); + +describe( 'findFirstInlineVideo', () => { + afterEach( () => { + document.body.innerHTML = ''; + } ); + + it( 'returns the first GoDAM video on the page', () => { + document.body.innerHTML = + ''; + expect( findFirstInlineVideo()?.id ).toBe( 'a' ); + } ); + + it( 'skips lightbox players', () => { + document.body.innerHTML = + '' + + ''; + expect( findFirstInlineVideo()?.id ).toBe( 'b' ); + } ); + + it( 'ignores videos that are not GoDAM players', () => { + document.body.innerHTML = ''; + expect( findFirstInlineVideo() ).toBeNull(); + } ); +} ); + +describe( 'resolveDeepLinkTarget', () => { + beforeEach( () => { + document.body.innerHTML = + '' + + '' + + ''; + } ); + + afterEach( () => { + document.body.innerHTML = ''; + } ); + + it( 'uses the video named in the hash, with the start time', () => { + expect( resolveDeepLinkTarget( { search: '?t=60', hash: '#godam-video-30' } ) ) + .toEqual( { targetId: '30', startTime: 60, fromHash: true } ); + } ); + + it( 'uses the hash without a start time', () => { + expect( resolveDeepLinkTarget( { search: '', hash: '#godam-video-10' } ) ) + .toEqual( { targetId: '10', startTime: null, fromHash: true } ); + } ); + + it( 'falls back to the first inline video when only ?t= is present', () => { + expect( resolveDeepLinkTarget( { search: '?t=45', hash: '' } ) ) + .toEqual( { targetId: '20', startTime: 45, fromHash: false } ); + } ); + + it( 'reads t alongside other query args', () => { + expect( resolveDeepLinkTarget( { search: '?p=7&t=5&utm_source=x', hash: '#comments' } ) ) + .toEqual( { targetId: '20', startTime: 5, fromHash: false } ); + } ); + + it( 'does nothing without a hash or a usable start time', () => { + expect( resolveDeepLinkTarget( { search: '', hash: '' } ) ).toBeNull(); + expect( resolveDeepLinkTarget( { search: '?t=abc', hash: '' } ) ).toBeNull(); + expect( resolveDeepLinkTarget( { search: '?t=0', hash: '' } ) ).toBeNull(); + } ); + + it( 'does nothing when ?t= is present but the page has no inline video', () => { + document.body.innerHTML = ''; + + expect( resolveDeepLinkTarget( { search: '?t=45', hash: '' } ) ).toBeNull(); + } ); +} ); diff --git a/inc/classes/class-seo.php b/inc/classes/class-seo.php index fc6b85f72..747215091 100644 --- a/inc/classes/class-seo.php +++ b/inc/classes/class-seo.php @@ -75,6 +75,14 @@ class Seo { */ private $pending_legacy_resaves = array(); + /** + * Shortest video (seconds) that gets key moments. Google ignores clips on + * videos under 30 seconds. + * + * @since n.e.x.t + */ + const KEY_MOMENTS_MIN_DURATION = 30; + /** * Content URLs already emitted by the cached wp_head schema output. * @@ -1077,6 +1085,15 @@ public function build_video_object_schema( $video, $post_id ) { $schema['duration'] = sanitize_text_field( $video['duration'] ); } + // Key moments: the video's chapters as Clip entries, so Google can show + // "jump to" links in search. Needs the host page to build each clip's URL. + if ( $attachment_id > 0 && $post_id > 0 ) { + $clips = $this->get_key_moment_clips( $attachment_id, $post_id, $schema['duration'] ?? '' ); + if ( ! empty( $clips ) ) { + $schema['hasPart'] = $clips; + } + } + /** * Filter an individual video SEO schema entry before output. * @@ -1095,6 +1112,273 @@ public function build_video_object_schema( $video, $post_id ) { return apply_filters( 'godam_video_seo_schema', $schema, $video, $post_id ); } + /** + * Build Google "key moments" for a video from its GoDAM chapters. + * + * Each chapter becomes a schema.org `Clip` with a deep link that seeks the + * player on the host page (`?t=#godam-video-`). The fragment + * is not an element anchor: the GoDAM player script reads it and matches + * `` against the player's `data-id` attribute. Read at output time from + * the attachment's `rtgodam_meta`, so editing chapters updates the schema + * without re-saving the page. Skipped for videos known to be shorter than + * {@see Seo::KEY_MOMENTS_MIN_DURATION} seconds. + * + * @see https://developers.google.com/search/docs/appearance/structured-data/video#clip + * + * @since n.e.x.t + * + * @param int $attachment_id Attachment ID. + * @param int $post_id Host post ID (the page the video is on). + * @param string $duration Video duration (ISO 8601), used to end the last clip. + * @return array[] Clip entries for `hasPart`, or an empty array. + */ + public function get_key_moment_clips( $attachment_id, $post_id, $duration = '' ) { + $attachment_id = absint( $attachment_id ); + $post_id = absint( $post_id ); + + if ( ! $attachment_id || ! $post_id ) { + return array(); + } + + /** + * Filters whether chapters are output as key moments (`hasPart` Clips). + * + * @since n.e.x.t + * + * @param bool $enabled Default true. + * @param int $attachment_id Attachment ID. + * @param int $post_id Host post ID. + */ + if ( ! apply_filters( 'godam_video_seo_key_moments_enabled', true, $attachment_id, $post_id ) ) { + return array(); + } + + /** + * Fires before reading this attachment's chapters (rtgodam_meta), so + * integrations that centralize media on another site can switch + * context first. + * + * @since n.e.x.t + */ + do_action( 'rtgodam_before_attachment_lookup' ); + $meta = get_post_meta( $attachment_id, 'rtgodam_meta', true ); + do_action( 'rtgodam_after_attachment_lookup' ); + + $chapters = ( is_array( $meta ) && ! empty( $meta['chapters'] ) && is_array( $meta['chapters'] ) ) ? $meta['chapters'] : array(); + if ( empty( $chapters ) ) { + return array(); + } + + $page_url = (string) get_permalink( $post_id ); // godam-coverage-ignore -- get_key_moment_clips(): $post_id is the host page, never an attachment. + if ( '' === $page_url ) { + return array(); + } + + $duration_seconds = self::iso8601_to_seconds( $duration ); + + // Google only shows key moments for videos of at least 30 seconds. + if ( $duration_seconds > 0 && $duration_seconds < self::KEY_MOMENTS_MIN_DURATION ) { + return array(); + } + + $clips = self::chapters_to_clips( $chapters, $page_url, $attachment_id, $duration_seconds ); + + /** + * Filters the key moment Clip entries output in a video's `hasPart`. + * + * @since n.e.x.t + * + * @param array[] $clips Clip entries. + * @param int $attachment_id Attachment ID. + * @param int $post_id Host post ID. + * @param array $chapters Raw chapters from rtgodam_meta. + */ + $clips = apply_filters( 'godam_video_seo_key_moments', $clips, $attachment_id, $post_id, $chapters ); + + return is_array( $clips ) ? array_values( $clips ) : array(); + } + + /** + * Convert GoDAM chapters into schema.org Clip entries. + * + * Chapters are sorted by start time and offsets are whole seconds, rounded + * down. Duplicate start seconds (the first chapter wins) and chapters + * starting at or after the end of the video are dropped first. A chapter's + * end is then its own `endTime` when set, otherwise the next kept + * chapter's start, otherwise the video duration, and never later than the + * next kept chapter's start or the duration. Chapters without a title, + * with an unreadable start, or with no length are skipped. + * + * Pure: no WordPress calls. + * + * @since n.e.x.t + * + * @param array $chapters Chapters (`text`, `startTime`, optional `endTime`, `originalTime`). + * @param string $page_url Host page URL. + * @param int $attachment_id Attachment ID, for the `#godam-video-` deep-link fragment. + * @param int $duration_seconds Video length in seconds, or 0 when unknown. + * @return array[] Clip entries. + */ + public static function chapters_to_clips( array $chapters, $page_url, $attachment_id, $duration_seconds = 0 ) { + $rows = array(); + + foreach ( $chapters as $chapter ) { + $chapter = (array) $chapter; + $name = trim( wp_strip_all_tags( (string) ( $chapter['text'] ?? '' ) ) ); + + $start_raw = ( isset( $chapter['startTime'] ) && '' !== (string) $chapter['startTime'] ) + ? $chapter['startTime'] + : ( $chapter['originalTime'] ?? null ); + $start = self::parse_time_to_seconds( $start_raw ); + + if ( '' === $name || null === $start || $start < 0 ) { + continue; + } + + $end = ( isset( $chapter['endTime'] ) && '' !== (string) $chapter['endTime'] ) + ? self::parse_time_to_seconds( $chapter['endTime'] ) + : null; + + $rows[] = array( + 'name' => $name, + 'start' => $start, + 'end' => $end, + ); + } + + usort( + $rows, + function ( $a, $b ) { + return $a['start'] <=> $b['start']; + } + ); + + // Strip any fragment from the page URL; the clip adds its own. + $base = (string) preg_replace( '/#.*$/', '', (string) $page_url ); + $sep = ( false === strpos( $base, '?' ) ) ? '?' : '&'; + + // Whole seconds, rounded down for both start and end, so a clip never + // overlaps the next one. De-duplicate (keeping the first chapter for a + // given second) and drop chapters past the end of the video *before* + // working out ends, so "next chapter" always means one that is output. + $kept = array(); + $seen_starts = array(); + + foreach ( $rows as $row ) { + $start_offset = (int) floor( $row['start'] ); + + if ( isset( $seen_starts[ $start_offset ] ) ) { + continue; + } + + if ( $duration_seconds > 0 && $start_offset >= $duration_seconds ) { + continue; + } + + $seen_starts[ $start_offset ] = true; + + $row['start_offset'] = $start_offset; + $kept[] = $row; + } + + $clips = array(); + $count = count( $kept ); + + for ( $i = 0; $i < $count; $i++ ) { + $start_offset = $kept[ $i ]['start_offset']; + $next_start = ( $i + 1 < $count ) ? $kept[ $i + 1 ]['start_offset'] : null; + + $end_offset = null; + if ( null !== $kept[ $i ]['end'] ) { + $end_offset = (int) floor( $kept[ $i ]['end'] ); + } elseif ( null !== $next_start ) { + $end_offset = $next_start; + } elseif ( $duration_seconds > 0 ) { + $end_offset = (int) $duration_seconds; + } + + $clip = array( + '@type' => 'Clip', + 'name' => $kept[ $i ]['name'], + 'startOffset' => $start_offset, + ); + + if ( null !== $end_offset ) { + // Never run into the next clip or past the end of the video. + if ( null !== $next_start ) { + $end_offset = min( $end_offset, $next_start ); + } + if ( $duration_seconds > 0 ) { + $end_offset = min( $end_offset, (int) $duration_seconds ); + } + if ( $end_offset <= $start_offset ) { + continue; + } + $clip['endOffset'] = $end_offset; + } + + $clip['url'] = $base . $sep . 't=' . $start_offset . '#godam-video-' . (int) $attachment_id; + $clips[] = $clip; + } + + return $clips; + } + + /** + * Parse a chapter time: plain seconds ("90", "12.5"), "mm:ss" or "hh:mm:ss(.ms)". + * + * @since n.e.x.t + * + * @param mixed $value Time value. + * @return float|null Seconds, or null when unreadable. + */ + public static function parse_time_to_seconds( $value ) { + if ( null === $value || is_array( $value ) || is_object( $value ) ) { + return null; + } + + $value = trim( (string) $value ); + + if ( '' === $value ) { + return null; + } + + if ( is_numeric( $value ) ) { + return (float) $value; + } + + if ( ! preg_match( '/^(?:(\d+):)?(\d{1,2}):(\d{1,2}(?:\.\d+)?)$/', $value, $m ) ) { + return null; + } + + $hours = '' !== $m[1] ? (int) $m[1] : 0; + $minutes = (int) $m[2]; + $seconds = (float) $m[3]; + + return $hours * 3600 + $minutes * 60 + $seconds; + } + + /** + * Convert an ISO 8601 duration (e.g. "PT1H2M3S") to whole seconds. + * + * @since n.e.x.t + * + * @param string $duration ISO 8601 duration. + * @return int Seconds, or 0 when unparseable. + */ + public static function iso8601_to_seconds( $duration ) { + if ( ! is_string( $duration ) || ! preg_match( '/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i', trim( $duration ), $m ) ) { + return 0; + } + + $days = isset( $m[1] ) ? (int) $m[1] : 0; + $hours = isset( $m[2] ) ? (int) $m[2] : 0; + $minutes = isset( $m[3] ) ? (int) $m[3] : 0; + $seconds = isset( $m[4] ) ? (float) $m[4] : 0; + + return (int) round( $days * 86400 + $hours * 3600 + $minutes * 60 + $seconds ); + } + /** * Queue a one-time background re-save of a page whose cached video SEO * was cached by an older GoDAM version. diff --git a/inc/classes/class-video-sitemap.php b/inc/classes/class-video-sitemap.php index 5526e20dc..ef74bd579 100644 --- a/inc/classes/class-video-sitemap.php +++ b/inc/classes/class-video-sitemap.php @@ -506,16 +506,7 @@ public static function is_valid_video( array $video ) { * @return int Seconds, or 0 when unparseable. */ public static function iso8601_duration_to_seconds( $duration ) { - if ( ! is_string( $duration ) || ! preg_match( '/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i', trim( $duration ), $m ) ) { - return 0; - } - - $days = isset( $m[1] ) ? (int) $m[1] : 0; - $hours = isset( $m[2] ) ? (int) $m[2] : 0; - $minutes = isset( $m[3] ) ? (int) $m[3] : 0; - $seconds = isset( $m[4] ) ? (float) $m[4] : 0; - - return (int) round( $days * 86400 + $hours * 3600 + $minutes * 60 + $seconds ); + return Seo::iso8601_to_seconds( $duration ); } /** diff --git a/tests/php/VideoSeoKeyMomentsTest.php b/tests/php/VideoSeoKeyMomentsTest.php new file mode 100644 index 000000000..ddc48d1df --- /dev/null +++ b/tests/php/VideoSeoKeyMomentsTest.php @@ -0,0 +1,434 @@ +?t=#godam-video-`. + * + * @package GoDAM + */ + +namespace RTGODAM\Tests; + +use PHPUnit\Framework\TestCase; +use RTGODAM\Inc\Seo; + +require_once dirname( __DIR__ ) . '/stubs/video-seo-functions.php'; +require_once dirname( __DIR__, 2 ) . '/inc/classes/class-seo.php'; + +/** + * @covers \RTGODAM\Inc\Seo + */ +class VideoSeoKeyMomentsTest extends TestCase { + + const PAGE = 'https://example.test/demo/'; + + /** + * @var Seo + */ + private $seo; + + protected function setUp(): void { + parent::setUp(); + $GLOBALS['rtgodam_stub'] = array(); + $GLOBALS['rtgodam_hooks'] = array(); + $this->seo = ( new \ReflectionClass( Seo::class ) )->newInstanceWithoutConstructor(); + } + + protected function tearDown(): void { + unset( $GLOBALS['rtgodam_stub'] ); + $GLOBALS['rtgodam_hooks'] = array(); + parent::tearDown(); + } + + /** + * Chapters as the video editor stores them in rtgodam_meta. + * + * @return array + */ + private function chapters() { + return array( + array( + 'id' => 'c2', + 'text' => 'On-page basics', + 'startTime' => '60', + ), + array( + 'id' => 'c1', + 'text' => 'Introduction', + 'startTime' => '0', + ), + array( + 'id' => 'c3', + 'text' => 'Technical SEO', + 'startTime' => '300.5', + ), + ); + } + + // ----------------------------------------------------------------------------------- + // chapters_to_clips(). + // ----------------------------------------------------------------------------------- + + public function test_chapters_become_sorted_clips_with_deep_links() { + $clips = Seo::chapters_to_clips( $this->chapters(), self::PAGE, 42, 600 ); + + $this->assertSame( + array( + array( + '@type' => 'Clip', + 'name' => 'Introduction', + 'startOffset' => 0, + 'endOffset' => 60, + 'url' => 'https://example.test/demo/?t=0#godam-video-42', + ), + array( + '@type' => 'Clip', + 'name' => 'On-page basics', + 'startOffset' => 60, + 'endOffset' => 300, + 'url' => 'https://example.test/demo/?t=60#godam-video-42', + ), + array( + '@type' => 'Clip', + 'name' => 'Technical SEO', + 'startOffset' => 300, + 'endOffset' => 600, + 'url' => 'https://example.test/demo/?t=300#godam-video-42', + ), + ), + $clips + ); + } + + public function test_explicit_end_time_is_used() { + $chapters = array( + array( + 'text' => 'Intro', + 'startTime' => '0', + 'endTime' => '25', + ), + array( + 'text' => 'Main', + 'startTime' => '40', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 100 ); + + $this->assertSame( 25, $clips[0]['endOffset'] ); + $this->assertSame( 100, $clips[1]['endOffset'] ); + } + + public function test_last_clip_has_no_end_when_duration_is_unknown() { + $clips = Seo::chapters_to_clips( $this->chapters(), self::PAGE, 42, 0 ); + + $this->assertArrayNotHasKey( 'endOffset', $clips[2] ); + $this->assertSame( 300, $clips[2]['startOffset'] ); + } + + public function test_end_is_clamped_to_the_video_duration() { + $chapters = array( + array( + 'text' => 'Only', + 'startTime' => '10', + 'endTime' => '999', + ), + ); + + $this->assertSame( 50, Seo::chapters_to_clips( $chapters, self::PAGE, 1, 50 )[0]['endOffset'] ); + } + + public function test_invalid_chapters_are_skipped() { + $chapters = array( + array( + 'text' => '', + 'startTime' => '5', + ), + array( + 'text' => 'No start', + 'startTime' => 'abc', + ), + array( + 'text' => 'After the end', + 'startTime' => '120', + ), + array( + 'text' => 'Valid', + 'startTime' => '10', + ), + array( + 'text' => 'Zero length', + 'startTime' => '20', + 'endTime' => '20', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 100 ); + + $this->assertSame( array( 'Valid' ), array_column( $clips, 'name' ) ); + } + + public function test_duplicate_start_times_keep_the_first_chapter() { + $chapters = array( + array( + 'text' => 'First', + 'startTime' => '0', + ), + array( + 'text' => 'Duplicate', + 'startTime' => '0.4', + ), + array( + 'text' => 'Second', + 'startTime' => '30', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 60 ); + + $this->assertSame( array( 'First', 'Second' ), array_column( $clips, 'name' ) ); + } + + public function test_duplicate_is_dropped_before_ends_are_worked_out() { + // "Recap" floors to the same second as "Intro" and is dropped. Intro must + // still end at Main's start, not at the dropped row's start (0). + $chapters = array( + array( + 'text' => 'Intro', + 'startTime' => '0', + ), + array( + 'text' => 'Recap', + 'startTime' => '0.4', + ), + array( + 'text' => 'Main', + 'startTime' => '30', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 120 ); + + $this->assertSame( array( 'Intro', 'Main' ), array_column( $clips, 'name' ) ); + $this->assertSame( array( 0, 30 ), array_column( $clips, 'startOffset' ) ); + $this->assertSame( array( 30, 120 ), array_column( $clips, 'endOffset' ) ); + } + + public function test_neighbouring_clips_never_overlap() { + $chapters = array( + array( + 'text' => 'A', + 'startTime' => '10.2', + 'endTime' => '20.7', + ), + array( + 'text' => 'B', + 'startTime' => '20.5', + ), + array( + 'text' => 'C', + 'startTime' => '40', + 'endTime' => '90', + ), + array( + 'text' => 'D', + 'startTime' => '60', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 100 ); + + $this->assertSame( array( 10, 20, 40, 60 ), array_column( $clips, 'startOffset' ) ); + // A's end rounds down; C's explicit end is capped at D's start. + $this->assertSame( array( 20, 40, 60, 100 ), array_column( $clips, 'endOffset' ) ); + + $count = count( $clips ); + for ( $i = 1; $i < $count; $i++ ) { + $this->assertLessThanOrEqual( $clips[ $i ]['startOffset'], $clips[ $i - 1 ]['endOffset'] ); + } + } + + public function test_chapter_names_are_stripped_of_html() { + $chapters = array( + array( + 'text' => 'Intro & welcome', + 'startTime' => '0', + ), + ); + + $this->assertSame( 'Intro & welcome', Seo::chapters_to_clips( $chapters, self::PAGE, 1, 10 )[0]['name'] ); + } + + public function test_original_time_is_used_when_start_time_is_missing() { + $chapters = array( + array( + 'text' => 'From original time', + 'originalTime' => '01:30', + ), + ); + + $this->assertSame( 90, Seo::chapters_to_clips( $chapters, self::PAGE, 1, 200 )[0]['startOffset'] ); + } + + public function test_url_uses_ampersand_for_plain_permalinks_and_drops_existing_fragment() { + $chapters = array( + array( + 'text' => 'Intro', + 'startTime' => '15', + ), + ); + + $this->assertSame( + 'https://example.test/?p=10&t=15#godam-video-7', + Seo::chapters_to_clips( $chapters, 'https://example.test/?p=10#comments', 7, 60 )[0]['url'] + ); + } + + /** + * @dataProvider time_provider + * + * @param mixed $value Input. + * @param float|null $expected Seconds. + */ + public function test_parse_time_to_seconds( $value, $expected ) { + $this->assertSame( $expected, Seo::parse_time_to_seconds( $value ) ); + } + + /** + * @return array + */ + public function time_provider() { + return array( + 'seconds' => array( '90', 90.0 ), + 'decimal' => array( '12.5', 12.5 ), + 'int' => array( 30, 30.0 ), + 'mm:ss' => array( '01:30', 90.0 ), + 'hh:mm:ss' => array( '1:02:03', 3723.0 ), + 'hh:mm:ss.ms' => array( '00:00:05.250', 5.25 ), + 'empty' => array( '', null ), + 'null' => array( null, null ), + 'garbage' => array( 'soon', null ), + 'array' => array( array( 1 ), null ), + ); + } + + public function test_iso8601_to_seconds() { + $this->assertSame( 3723, Seo::iso8601_to_seconds( 'PT1H2M3S' ) ); + $this->assertSame( 0, Seo::iso8601_to_seconds( 'nope' ) ); + } + + // ----------------------------------------------------------------------------------- + // In the schema. + // ----------------------------------------------------------------------------------- + + /** + * A cached SEO entry for attachment 42. + * + * @return array + */ + private function entry() { + return array( + 'headline' => 'SEO basics', + 'description' => 'Learn SEO.', + 'duration' => 'PT10M', + 'thumbnailUrl' => 'https://cdn.example.test/t.jpg', + 'uploadDate' => '2026-01-15T08:00:00+00:00', + 'attachment_id' => 42, + ); + } + + public function test_schema_has_part_lists_chapters_as_clips() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $schema = $this->seo->build_video_object_schema( $this->entry(), 10 ); + + $this->assertCount( 3, $schema['hasPart'] ); + $this->assertSame( 'Clip', $schema['hasPart'][0]['@type'] ); + $this->assertSame( 'https://shop.test/?p=10&t=60#godam-video-42', $schema['hasPart'][1]['url'] ); + // Last clip ends at the video duration (PT10M). + $this->assertSame( 600, $schema['hasPart'][2]['endOffset'] ); + } + + public function test_no_has_part_for_videos_under_30_seconds() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $entry = $this->entry(); + $entry['duration'] = 'PT29S'; + + $this->assertArrayNotHasKey( 'hasPart', $this->seo->build_video_object_schema( $entry, 10 ) ); + + $entry['duration'] = 'PT30S'; + $schema = $this->seo->build_video_object_schema( $entry, 10 ); + + // 30 seconds is enough; the 60s and 300s chapters are past the end. + $this->assertSame( array( 'Introduction' ), array_column( $schema['hasPart'], 'name' ) ); + } + + public function test_has_part_kept_when_duration_is_unknown() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $entry = $this->entry(); + unset( $entry['duration'] ); + + $this->assertCount( 3, $this->seo->build_video_object_schema( $entry, 10 )['hasPart'] ); + } + + public function test_no_has_part_without_chapters() { + $schema = $this->seo->build_video_object_schema( $this->entry(), 10 ); + + $this->assertArrayNotHasKey( 'hasPart', $schema ); + } + + public function test_no_has_part_without_a_host_page() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + // Render-time output on an archive / 404: no page to deep-link into. + $schema = $this->seo->build_video_object_schema( $this->entry(), 0 ); + + $this->assertArrayNotHasKey( 'hasPart', $schema ); + } + + public function test_no_has_part_without_an_attachment() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $entry = $this->entry(); + unset( $entry['attachment_id'] ); + + $this->assertArrayNotHasKey( 'hasPart', $this->seo->build_video_object_schema( $entry, 10 ) ); + } + + public function test_key_moments_can_be_turned_off_with_a_filter() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + add_filter( + 'godam_video_seo_key_moments_enabled', + function () { + return false; + } + ); + + $this->assertArrayNotHasKey( 'hasPart', $this->seo->build_video_object_schema( $this->entry(), 10 ) ); + } + + public function test_clips_filter_can_change_the_output() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + add_filter( + 'godam_video_seo_key_moments', + function ( $clips ) { + return array_slice( $clips, 0, 1 ); + } + ); + + $this->assertCount( 1, $this->seo->build_video_object_schema( $this->entry(), 10 )['hasPart'] ); + } + + public function test_has_part_is_valid_json() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $json = wp_json_encode( $this->seo->build_video_object_schema( $this->entry(), 10 ) ); + $decoded = json_decode( $json, true ); + + $this->assertSame( 0, $decoded['hasPart'][0]['startOffset'] ); + $this->assertIsInt( $decoded['hasPart'][0]['endOffset'] ); + } +} diff --git a/tests/stubs/video-seo-functions.php b/tests/stubs/video-seo-functions.php index 19d47be3b..a196d8bdb 100644 --- a/tests/stubs/video-seo-functions.php +++ b/tests/stubs/video-seo-functions.php @@ -181,6 +181,20 @@ function update_post_meta( $post_id, $key, $value ) { } } + if ( ! function_exists( 'get_permalink' ) ) { + + /** + * Same shape as tests/stubs/product-lookup-functions.php's get_permalink() + * (default "shop" site), so results match whichever stub loads first. + * + * @param int $post Post ID. + * @return string + */ + function get_permalink( $post = 0 ) { + return 'https://shop.test/?p=' . (int) $post; + } + } + if ( ! function_exists( 'update_option' ) ) { /**