@@ -833,6 +833,150 @@ describe('Product HTML Pipe Test', () => {
833833 fetchMock . unmockGlobal ( ) ;
834834 } ) ;
835835
836+ it ( 'renders hreflang metadata as link tags in head' , async ( ) => {
837+ fetchMock . unmockGlobal ( ) ;
838+ fetchMock . removeRoutes ( ) ;
839+ const fetchMockGlobal = fetchMock . mockGlobal ( ) ;
840+
841+ fetchMockGlobal . get ( 'https://main--site--org.aem.live/products/hreflang' , { status : 404 } ) ;
842+
843+ const s3Loader = new FileS3Loader ( ) ;
844+ const state = DEFAULT_STATE ( DEFAULT_CONFIG , {
845+ log : console ,
846+ s3Loader,
847+ ref : 'main' ,
848+ path : '/products/hreflang' ,
849+ partition : 'live' ,
850+ timer : { update : ( ) => { } } ,
851+ } ) ;
852+ state . info = getPathInfo ( '/products/hreflang' ) ;
853+
854+ const resp = await productHTMLPipe (
855+ state ,
856+ new PipelineRequest ( new URL ( 'https://acme.com/products/hreflang' ) ) ,
857+ ) ;
858+
859+ assert . strictEqual ( resp . status , 200 ) ;
860+
861+ // standard hreflang entries emit as <link rel="alternate"> tags
862+ assert . ok (
863+ resp . body . includes ( '<link rel="alternate" hreflang="x-default" href="https://www.blendify.com/products/blitzmax-5000">' ) ,
864+ 'Should have x-default hreflang link tag' ,
865+ ) ;
866+ assert . ok (
867+ resp . body . includes ( '<link rel="alternate" hreflang="en-us" href="https://www.blendify.com/us/en_us/shop/blitzmax-5000">' ) ,
868+ 'Should have en-us hreflang link tag' ,
869+ ) ;
870+ assert . ok (
871+ resp . body . includes ( '<link rel="alternate" hreflang="de" href="https://www.blendify.com/de/de/shop/blitzmax-5000">' ) ,
872+ 'Should have de hreflang link tag' ,
873+ ) ;
874+
875+ // underscore in locale is replaced with hyphen and lowercased (hreflang-fr_FR → fr-fr)
876+ assert . ok (
877+ resp . body . includes ( '<link rel="alternate" hreflang="fr-fr" href="https://www.blendify.com/fr/fr/shop/blitzmax-5000">' ) ,
878+ 'Should normalize underscore locale separator to hyphen' ,
879+ ) ;
880+
881+ // prefix detection is case-insensitive (HREFLANG-en-au → en-au)
882+ assert . ok (
883+ resp . body . includes ( '<link rel="alternate" hreflang="en-au" href="https://www.blendify.com/au/en/shop/blitzmax-5000">' ) ,
884+ 'Should detect hreflang prefix case-insensitively' ,
885+ ) ;
886+
887+ // hreflang- with no locale suffix is silently skipped
888+ assert . ok (
889+ ! resp . body . includes ( 'skip-empty-locale' ) ,
890+ 'Should skip hreflang- entry with empty locale suffix' ,
891+ ) ;
892+
893+ // hreflang-it with empty URL is silently skipped
894+ assert . ok (
895+ ! resp . body . includes ( 'hreflang="it"' ) ,
896+ 'Should skip hreflang entry with empty URL' ,
897+ ) ;
898+
899+ // hreflang-* entries must NOT also appear as <meta> tags
900+ assert . ok (
901+ ! resp . body . includes ( '<meta name="hreflang' ) ,
902+ 'hreflang entries must not be emitted as meta tags' ,
903+ ) ;
904+
905+ // non-hreflang metadata should still be emitted as <meta> tags
906+ assert . ok (
907+ resp . body . includes ( '<meta name="robots" content="noindex">' ) ,
908+ 'Non-hreflang metadata should still emit as meta tags' ,
909+ ) ;
910+
911+ fetchMock . unmockGlobal ( ) ;
912+ } ) ;
913+
914+ it ( 'ignores hreflang-* meta tags from authored content' , async ( ) => {
915+ fetchMock . unmockGlobal ( ) ;
916+ fetchMock . removeRoutes ( ) ;
917+ const fetchMockGlobal = fetchMock . mockGlobal ( ) ;
918+
919+ // Authored content with hreflang-* meta tags in head — these should be ignored
920+ fetchMockGlobal . get ( 'https://main--site--org.aem.live/products/product-simple' , {
921+ body : `<!DOCTYPE html>
922+ <html>
923+ <head>
924+ <title>Test</title>
925+ <meta name="hreflang-de" content="https://example.com/de/product">
926+ <meta name="hreflang-fr-FR" content="https://example.com/fr/product">
927+ <meta name="author" content="Test Author">
928+ </head>
929+ <body>
930+ <main><div><p>Content</p></div></main>
931+ </body>
932+ </html>` ,
933+ headers : {
934+ 'content-type' : 'text/html' ,
935+ 'last-modified' : 'Fri, 30 Apr 2021 03:47:18 GMT' ,
936+ } ,
937+ } ) ;
938+
939+ const s3Loader = new FileS3Loader ( ) ;
940+ const state = DEFAULT_STATE ( DEFAULT_CONFIG , {
941+ log : console ,
942+ s3Loader,
943+ ref : 'main' ,
944+ path : '/products/product-simple' ,
945+ partition : 'live' ,
946+ timer : { update : ( ) => { } } ,
947+ } ) ;
948+ state . info = getPathInfo ( '/products/product-simple' ) ;
949+
950+ const resp = await productHTMLPipe (
951+ state ,
952+ new PipelineRequest ( new URL ( 'https://acme.com/products/product-simple' ) ) ,
953+ ) ;
954+
955+ assert . strictEqual ( resp . status , 200 ) ;
956+
957+ // hreflang-* from authored content must be ignored entirely
958+ assert . ok (
959+ ! resp . body . includes ( 'hreflang-de' ) ,
960+ 'hreflang-de from authored content must not appear in output' ,
961+ ) ;
962+ assert . ok (
963+ ! resp . body . includes ( 'hreflang-fr' ) ,
964+ 'hreflang-fr-FR from authored content must not appear in output' ,
965+ ) ;
966+ assert . ok (
967+ ! resp . body . includes ( 'example.com/de/product' ) && ! resp . body . includes ( 'example.com/fr/product' ) ,
968+ 'hreflang URLs from authored content must not appear in output' ,
969+ ) ;
970+
971+ // Non-hreflang authored metadata should still pass through
972+ assert . ok (
973+ resp . body . includes ( '<meta name="author" content="Test Author">' ) ,
974+ 'Non-hreflang authored metadata should still be extracted' ,
975+ ) ;
976+
977+ fetchMock . unmockGlobal ( ) ;
978+ } ) ;
979+
836980 it ( 'handles authored content with meta tags without name value' , async ( ) => {
837981 // Clear any existing mocks and set up fresh
838982 fetchMock . unmockGlobal ( ) ;
0 commit comments