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' ) ) {
/**