Skip to content

Commit b240da6

Browse files
committed
feat: hreflang in metadata support
1 parent e0c784f commit b240da6

4 files changed

Lines changed: 187 additions & 1 deletion

File tree

src/steps/extract-authored-metadata.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ const IGNORED_META_NAMES = new Set([
2424
/**
2525
* Prefixes for meta tag names to ignore.
2626
* og: and twitter: prefixes are standard social meta tags.
27+
* hreflang- entries are handled exclusively via product metadata, not authored content.
2728
*/
28-
const IGNORED_META_PREFIXES = ['og:', 'twitter:'];
29+
const IGNORED_META_PREFIXES = ['og:', 'twitter:', 'hreflang-'];
2930

3031
/**
3132
* Check if a meta tag name should be ignored.

src/steps/render-head.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import rehypeParse from 'rehype-parse';
1818
import { constructImageUrl } from './create-pictures.js';
1919
import { limitWords, stripHTML } from './utils.js';
2020

21+
const HREFLANG_PREFIX = 'hreflang-';
22+
2123
/**
2224
* @param {PipelineState} state
2325
* @returns {Promise<void>}
@@ -63,6 +65,14 @@ export default async function render(state) {
6365
// Add product metadata to the head
6466
if (metadata) {
6567
Object.entries(metadata).forEach(([key, value]) => {
68+
if (key.toLowerCase().startsWith(HREFLANG_PREFIX)) {
69+
const localeRaw = key.substring(HREFLANG_PREFIX.length);
70+
if (localeRaw && value) {
71+
const hreflang = localeRaw.replaceAll('_', '-').toLowerCase();
72+
head.children.push(h('link', { rel: 'alternate', hreflang, href: value }));
73+
}
74+
return;
75+
}
6676
head.children.push(h('meta', { name: key, content: value }));
6777
});
6878
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"sku": "BlitzMax 5000",
3+
"urlKey": "blitzmax-5000",
4+
"description": "The BlitzMax 5000 is the ultimate blending powerhouse from Blendify.",
5+
"name": "BlitzMax 5000",
6+
"type": "simple",
7+
"metaTitle": "Blendify BlitzMax 5000 Blender",
8+
"metaDescription": "The BlitzMax 5000 is the ultimate blending powerhouse from Blendify.",
9+
"url": "https://www.blendify.com/us/en_us/shop/blitzmax-5000",
10+
"images": [
11+
{ "url": "./media_a1b2c3d4e5f6789012345678901234567890abcd.png" }
12+
],
13+
"brand": "Blendify",
14+
"availability": "InStock",
15+
"price": {
16+
"currency": "USD",
17+
"regular": "399.95",
18+
"final": "349.95"
19+
},
20+
"metadata": {
21+
"hreflang-x-default": "https://www.blendify.com/products/blitzmax-5000",
22+
"hreflang-en-us": "https://www.blendify.com/us/en_us/shop/blitzmax-5000",
23+
"hreflang-de": "https://www.blendify.com/de/de/shop/blitzmax-5000",
24+
"hreflang-fr_FR": "https://www.blendify.com/fr/fr/shop/blitzmax-5000",
25+
"HREFLANG-en-au": "https://www.blendify.com/au/en/shop/blitzmax-5000",
26+
"hreflang-": "https://www.blendify.com/skip-empty-locale",
27+
"hreflang-it": "",
28+
"robots": "noindex"
29+
},
30+
"variants": []
31+
}

test/product-html-pipe.test.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)