Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/src/blocks/godam-player/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@
"type": "boolean",
"default": false
},
"excludeFromVideoSitemap": {
"type": "boolean",
"default": false
},
"verticalAlignment": {
"type": "string",
"default": "center"
Expand Down
34 changes: 30 additions & 4 deletions assets/src/blocks/godam-player/components/VideoSEOModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import apiFetch from '@wordpress/api-fetch';
* Internal dependencies
*/
import './video-seo-modal.scss';
import { appendTimezoneOffsetToUTC, stripHtmlTags } from '../utils';
import { appendTimezoneOffsetToUTC, stripHtmlTags, getFileNameFromUrl, isFilenameLikeTitle } from '../utils';

/**
* Video SEO Modal component
Expand All @@ -39,6 +39,8 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri
const [ mediaSEOData, setMediaSEOData ] = useState( null );
const [ isLoadingMediaSEO, setIsLoadingMediaSEO ] = useState( false );
const [ seoOverride, setSeoOverride ] = useState( attributes?.seoOverride || false );
const [ excludeFromSitemap, setExcludeFromSitemap ] = useState( !! attributes?.excludeFromVideoSitemap );
const [ mediaFileName, setMediaFileName ] = useState( getFileNameFromUrl( attributes?.src || '' ) );
const posterThumbnail = attributes?.poster || '';

/**
Expand All @@ -53,8 +55,9 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri
try {
const response = await apiFetch( { path: `/wp/v2/media/${ attributes.id }` } );

// Google indexes HLS (.m3u8) but not DASH (.mpd), so prefer the HLS stream.
const seoFromMedia = {
contentUrl: response.meta?.rtgodam_transcoded_url || response.source_url || '',
contentUrl: response.meta?.rtgodam_hls_transcoded_url || response.source_url || '',
headline: response.title?.rendered || '',
description: stripHtmlTags( response.description?.rendered || '' ),
uploadDate: appendTimezoneOffsetToUTC( response.date_gmt || '' ),
Expand All @@ -64,6 +67,9 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri
};

setMediaSEOData( seoFromMedia );
if ( response.source_url ) {
setMediaFileName( getFileNameFromUrl( response.source_url ) );
}
return seoFromMedia;
} catch ( error ) {
// eslint-disable-next-line no-console
Expand All @@ -82,6 +88,7 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri
// Sync local override state with attributes
const currentSeoOverride = attributes?.seoOverride || false;
setSeoOverride( currentSeoOverride );
setExcludeFromSitemap( !! attributes?.excludeFromVideoSitemap );

// If seoOverride is false, fetch and display fresh data from media library
if ( ! currentSeoOverride ) {
Expand Down Expand Up @@ -120,7 +127,7 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri

// Also fetch media SEO for comparison/reset purposes
fetchMediaSEOData();
}, [ attributes.seo, attributes?.seoOverride, attributes?.poster, isOpen, fetchMediaSEOData ] ); // Depend on seo attribute and modal state
}, [ attributes.seo, attributes?.seoOverride, attributes?.excludeFromVideoSitemap, attributes?.poster, isOpen, fetchMediaSEOData ] ); // Depend on seo attribute and modal state

const updateField = ( field, value ) => {
setVideoData( { ...videoData, [ field ]: value } );
Expand Down Expand Up @@ -181,6 +188,7 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri
setVideoData( mediaSEOData );
}
setSeoOverride( attributes?.seoOverride || false );
setExcludeFromSitemap( !! attributes?.excludeFromVideoSitemap );
};

const saveData = () => {
Expand All @@ -192,6 +200,7 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri
setAttributes( {
seo: finalVideoData,
seoOverride,
excludeFromVideoSitemap: excludeFromSitemap,
} );
closeModal();
};
Expand All @@ -203,6 +212,8 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri
// Check if fields should be editable (only when override is enabled)
const isFieldsDisabled = ! seoOverride;

const isHeadlineFilenameLike = isFilenameLikeTitle( videoData?.headline, mediaFileName );

return (
<Modal
title={ __( 'Video SEO Schema', 'godam' ) }
Expand Down Expand Up @@ -248,7 +259,7 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri
label={ __( 'Content URL', 'godam' ) }
value={ videoData?.contentUrl || '' }
onChange={ ( value ) => updateField( 'contentUrl', value ) }
help={ __( 'URL of the video content can be MOV, MP4, MPD. Example: https://www.example.com/video.mp4', 'godam' ) }
help={ __( 'URL of the video file or HLS (.m3u8) stream that search engines index. Example: https://www.example.com/video.mp4', 'godam' ) }
disabled={ true }
/>
<TextControl
Expand All @@ -259,6 +270,14 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri
help={ __( 'Title of the video', 'godam' ) }
disabled={ isFieldsDisabled }
/>
{ isHeadlineFilenameLike && (
<Notice status="warning" isDismissible={ false } className="godam-seo-modal__notice godam-seo-modal__notice--filename">
{ seoOverride
? __( 'This title looks like a file name. Search engines show it as the video title, so use a descriptive title with spaces instead.', 'godam' )
: __( 'This title looks like a file name. Search engines show it as the video title. Edit the video title in the Media Library, or turn on "Override default SEO" to set a descriptive title for this block.', 'godam' )
}
</Notice>
) }
<TextareaControl
className={ `godam-seo-modal__property${ ! videoData?.description?.trim() ? ' godam-seo-modal__property--warning' : '' }` }
label={ __( 'Description', 'godam' ) }
Expand Down Expand Up @@ -302,6 +321,13 @@ export default function VideoSEOModal( { isOpen, setIsOpen, attributes, setAttri
help={ __( 'Is the video suitable for all audiences?', 'godam' ) }
disabled={ isFieldsDisabled }
/>
<ToggleControl
className="godam-seo-modal__property"
label={ __( 'Exclude from video sitemap', 'godam' ) }
checked={ excludeFromSitemap }
onChange={ ( value ) => setExcludeFromSitemap( value ) }
help={ __( 'Leave this video out of the GoDAM video sitemap. The video schema on this page is not affected.', 'godam' ) }
/>

<div style={ { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '16px' } }>
<Button variant="tertiary" onClick={ closeModal }>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@
}
}

// "Title looks like a file name" warning under the Headline field:
// same gap below it as between the property boxes.
&__notice--filename {
margin-top: 0;
margin-bottom: 1rem;
}

&__property {

background-color: rgb(248, 249, 250);
Expand Down
7 changes: 4 additions & 3 deletions assets/src/blocks/godam-player/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ function VideoEdit( {
const response = await apiFetch( { path: `/wp/v2/media/${ id }` } );

const enhancedSEOData = {
contentUrl: response.meta?.rtgodam_transcoded_url || response.source_url || src || '',
contentUrl: response.meta?.rtgodam_hls_transcoded_url || response.source_url || src || '',
headline: response.title?.rendered || '',
description: stripHtmlTags( response.description?.rendered || '' ),
uploadDate: appendTimezoneOffsetToUTC( response.date_gmt || '' ),
Expand Down Expand Up @@ -535,8 +535,9 @@ function VideoEdit( {
}

// Create new SEO data from GoDAM media
// Prefer the HLS stream: Google indexes .m3u8 but not DASH .mpd.
const newSEOData = {
contentUrl: media?.url,
contentUrl: media?.hls_url || media?.url,
headline: media?.title || '',
description: stripHtmlTags( media?.description || '' ),
uploadDate: appendTimezoneOffsetToUTC( media?.date || '' ),
Expand Down Expand Up @@ -597,7 +598,7 @@ function VideoEdit( {

// Create new SEO data from WordPress media
const newSEOData = {
contentUrl: response.meta?.rtgodam_transcoded_url || response.source_url,
contentUrl: response.meta?.rtgodam_hls_transcoded_url || response.source_url,
headline: response.title?.rendered || '',
description: stripHtmlTags( response.description?.rendered || '' ),
uploadDate: appendTimezoneOffsetToUTC( response.date_gmt ),
Expand Down
113 changes: 112 additions & 1 deletion assets/src/blocks/godam-player/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,115 @@ function stripHtmlTags( html ) {
return doc.body.textContent || '';
}

export { getFirstNonEmpty, appendTimezoneOffsetToUTC, isObjectEmpty, secondsToISO8601, isSEODataEmpty, stripHtmlTags };
/**
* Get the file name (without folders or query string) from a URL or path.
*
* @param {string} url - URL or path.
* @return {string} File name, e.g. "my-video_final.mp4", or ''.
*/
function getFileNameFromUrl( url ) {
if ( ! url || typeof url !== 'string' ) {
return '';
}

const path = url.split( /[?#]/ )[ 0 ];
const name = path.substring( path.lastIndexOf( '/' ) + 1 );

try {
return decodeURIComponent( name );
} catch {
return name;
}
}

/**
* Remove a trailing file extension, e.g. "clip.final.mp4" -> "clip.final".
*
* @param {string} name - File name.
* @return {string} Name without the last extension.
*/
function stripFileExtension( name ) {
return String( name || '' ).replace( /\.[a-z0-9]{2,5}$/i, '' );
}

/**
* Normalize a title or file name for comparison: lower case, and runs of
* spaces, hyphens and underscores collapsed to "-".
*
* WordPress builds the title from the uploaded name with its spaces but saves
* the file with hyphens, e.g. title "Screen Recording 2024-01-15 at 10.30.45"
* and file "Screen-Recording-2024-01-15-at-10.30.45.mov"; both normalize to
* the same string.
*
* @param {string} value - Title or file name.
* @return {string} Normalized value.
*/
function normalizeForFileNameComparison( value ) {
return String( value || '' ).trim().toLowerCase()
.replace( /[\s_-]+/g, '-' )
.replace( /^-+|-+$/g, '' );
}

/**
* Whether a video title looks like a raw file name rather than a real title.
*
* A single hyphen or underscore is not enough on its own: real titles such as
* "Spider-Man", "COVID-19", "e-commerce", "Add-ons", "Q3_review" or
* "Paris-2024" have one. A title is flagged when it matches the video's file
* name (ignoring case, the extension, and the difference between spaces,
* hyphens and underscores) *and* also looks machine-made: no spaces, or a
* date or time in it ("Screen Recording 2024-01-15 at 10.30.45"). WordPress
* builds the title from the uploaded file name, so a well-named upload
* ("Product Demo.mp4" → "Product Demo") always matches its file and must
* not be flagged for that alone. A title with no spaces is also flagged when it
* has two or more hyphens/underscores ("product-demo_v2"), is a camera or
* phone file name ("IMG_2041", "DSC-0001", "PXL_20240115"), or ends in a
* video file extension ("clip.mp4").
*
* @param {string} title - Video title / SEO headline.
* @param {string} [fileName] - The video's file name or URL, if known.
* @return {boolean} True when the title looks like a file name.
*/
function isFilenameLikeTitle( title, fileName = '' ) {
const trimmed = String( title || '' ).trim();

if ( ! trimmed ) {
return false;
}

const baseName = getFileNameFromUrl( fileName );
if ( baseName ) {
const normalizedTitle = normalizeForFileNameComparison( trimmed );
const normalizedFile = normalizeForFileNameComparison( baseName );
const normalizedFileNoExtension = normalizeForFileNameComparison( stripFileExtension( baseName ) );

const looksGenerated = ! /\s/.test( trimmed ) || /\d{4}-\d{2}-\d{2}|\d{1,2}\.\d{2}\.\d{2}/.test( trimmed );

if ( looksGenerated && normalizedTitle && ( normalizedTitle === normalizedFile || normalizedTitle === normalizedFileNoExtension ) ) {
return true;
}
}

if ( /\s/.test( trimmed ) ) {
return false;
}

// Two or more separators, e.g. "product-demo_v2".
if ( ( trimmed.match( /[-_]/g ) || [] ).length >= 2 ) {
return true;
}

// Camera / phone naming, e.g. "IMG_2041", "DSC-0001", "VID_20240115", "PXL_20240115".
if ( /^(img|vid|dsc|dscn|mvi|pxl|mov|dji|gopr|gp)[-_]?\d{3,}$/i.test( trimmed ) ) {
return true;
}

// A single word ending in a video file extension, e.g. "IMG2041.mp4".
if ( /\.(mp4|mov|m4v|webm|mkv|avi|wmv|flv|mpe?g|3gp|ogv|m3u8|mpd)$/i.test( trimmed ) ) {
return true;
}

return false;
}

export { getFirstNonEmpty, appendTimezoneOffsetToUTC, isObjectEmpty, secondsToISO8601, isSEODataEmpty, stripHtmlTags, getFileNameFromUrl, isFilenameLikeTitle };
93 changes: 93 additions & 0 deletions assets/src/blocks/godam-player/utils/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Internal dependencies
*/
import { getFileNameFromUrl, isFilenameLikeTitle } from './index';

describe( 'isFilenameLikeTitle', () => {
it.each( [
[ 'product-demo_v2', '' ],
[ 'my-video-final', '' ],
[ 'final_cut_FINAL', '' ],
[ 'Mike-was-impressed-by-Harvey_s-influence', '' ],
[ 'IMG_2041', '' ],
[ 'DSC-0001', '' ],
[ 'VID_20240115', '' ],
[ 'IMG2041.mp4', '' ],
[ 'clip.MOV', '' ],
] )( 'flags "%s" (2+ separators, camera-style name or file extension, no spaces)', ( title, fileName ) => {
expect( isFilenameLikeTitle( title, fileName ) ).toBe( true );
} );

it.each( [
'Spider-Man',
'COVID-19',
'e-commerce',
'Add-ons',
'Q3_review',
'Self-hosting',
'Paris-2024',
'Euro-2024',
'CES-2025',
] )( 'does not flag the real one-separator title "%s"', ( title ) => {
expect( isFilenameLikeTitle( title ) ).toBe( false );
} );

it( 'flags a title WordPress made from the uploaded file name', () => {
expect(
isFilenameLikeTitle( 'Screen Recording 2024-01-15 at 10.30.45', 'https://example.test/uploads/Screen-Recording-2024-01-15-at-10.30.45.mov' ),
).toBe( true );
expect( isFilenameLikeTitle( 'Screen Recording 2024-01-15 at 10.30.45', 'Screen_Recording_2024-01-15_at_10.30.45.mp4' ) ).toBe( true );
} );

it.each( [
[ 'Product Demo', 'https://example.test/uploads/Product-Demo.mp4' ],
[ 'How to Set Up Your Store', 'https://example.test/uploads/How-to-Set-Up-Your-Store.mp4' ],
[ 'my video final', 'my_video_final.mp4' ],
[ 'My Clip', 'https://example.test/uploads/My%20Clip.mov?ver=2' ],
] )( 'does not flag the well-named upload "%s" just because it matches its file name', ( title, fileName ) => {
expect( isFilenameLikeTitle( title, fileName ) ).toBe( false );
} );

it( 'flags camera-style names', () => {
[ 'PXL_20240115', 'MVI_0042', 'DJI_0001', 'GOPR0123' ].forEach( ( title ) => {
expect( isFilenameLikeTitle( title ) ).toBe( true );
} );
} );

it( 'flags a one-separator title when it is the file name', () => {
expect( isFilenameLikeTitle( 'my-video', 'https://example.test/uploads/my-video.mp4' ) ).toBe( true );
} );

it( 'flags a title that matches the file name, with or without extension', () => {
expect( isFilenameLikeTitle( 'Intro', 'https://example.test/wp-content/uploads/2026/10/intro.mp4' ) ).toBe( true );
expect( isFilenameLikeTitle( 'intro.mp4', 'intro.mp4' ) ).toBe( true );
} );

it.each( [
'How to set up GoDAM',
'Product demo - part 2',
'Q3_results review',
'Intro',
'',
' ',
] )( 'does not flag "%s"', ( title ) => {
expect( isFilenameLikeTitle( title, 'https://example.test/uploads/other-file.mp4' ) ).toBe( false );
} );

it( 'handles missing values', () => {
expect( isFilenameLikeTitle( undefined ) ).toBe( false );
expect( isFilenameLikeTitle( null, null ) ).toBe( false );
} );
} );

describe( 'getFileNameFromUrl', () => {
it( 'returns the decoded file name without folders or query string', () => {
expect( getFileNameFromUrl( 'https://cdn.example.test/a/b/My%20Video.mp4?x=1#t=3' ) ).toBe( 'My Video.mp4' );
expect( getFileNameFromUrl( 'clip.mov' ) ).toBe( 'clip.mov' );
} );

it( 'returns an empty string for empty input', () => {
expect( getFileNameFromUrl( '' ) ).toBe( '' );
expect( getFileNameFromUrl( undefined ) ).toBe( '' );
} );
} );
Loading
Loading