Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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