From c1dcf17d16a1d1a63d82259f21bc890a1a1c8e7a Mon Sep 17 00:00:00 2001 From: rofe Date: Tue, 18 Mar 2025 15:32:41 +0100 Subject: [PATCH 1/3] feat: add language-support feature --- src/steps/render.js | 45 +++++++++++++++++++ .../en/uk/page-metadata-language-support.html | 32 +++++++++++++ .../en/uk/page-metadata-language-support.md | 9 ++++ .../page-metadata-language-support.html | 32 +++++++++++++ .../content/page-metadata-language-support.md | 9 ++++ test/rendering.test.js | 37 +++++++++++++++ 6 files changed, 164 insertions(+) create mode 100644 test/fixtures/content/en/uk/page-metadata-language-support.html create mode 100644 test/fixtures/content/en/uk/page-metadata-language-support.md create mode 100644 test/fixtures/content/page-metadata-language-support.html create mode 100644 test/fixtures/content/page-metadata-language-support.md diff --git a/src/steps/render.js b/src/steps/render.js index 2532f589..9abd4a90 100644 --- a/src/steps/render.js +++ b/src/steps/render.js @@ -41,6 +41,19 @@ function sanitizeJsonLd(jsonLd) { return JSON.stringify(JSON.parse(sanitizedJsonLd.trim()), null, 2); } +function getLangHref(path, currentPrefix, prefix, canonical) { + if (currentPrefix === prefix) { + // current prefix is identical to prefix -> canonical + return canonical; + } else if (!currentPrefix) { + // current prefix empty -> prepend prefix + return new URL(`${prefix}${path}`, canonical).href; + } else { + // replace current prefix with prefix + return new URL(path.replace(currentPrefix, prefix), canonical).href; + } +} + /** * @type PipelineStep * @param {PipelineState} state @@ -116,6 +129,38 @@ export default async function render(state, req, res) { $head.children.push(...$headHtml.children); } + // language support + const { langs, defaultLang } = state.config.features?.['language-support'] || {}; + if (langs) { + const path = state.info.originalPath; + // find lang with longest matching prefix + const { lang: currentLang, prefix: currentPrefix } = langs.reduce((acc, lang) => { + if (path.startsWith(`${lang.prefix}/`) && (!acc || lang.prefix.length > acc.prefix.length)) { + return lang; + } + return acc; + }); + if (currentLang) { + // set html lang if not already set via metadata + if (!htmlLang) { + htmlLang = currentLang; + } + // inject hreflang links + langs.forEach(({ lang, prefix }) => { + const href = getLangHref(path, currentPrefix, prefix, meta.canonical); + $head.children.push(createElement('link', 'rel', 'alternate', 'hreflang', lang, 'href', href)); + }); + // write x-default hreflang link if default lang exists + if (defaultLang) { + const { lang, prefix } = langs.find((l) => l.lang === defaultLang); + if (lang) { + const href = getLangHref(path, currentPrefix, prefix, meta.canonical); + $head.children.push(createElement('link', 'rel', 'alternate', 'hreflang', 'x-default', 'href', href)); + } + } + } + } + res.document = { type: 'root', children: [ diff --git a/test/fixtures/content/en/uk/page-metadata-language-support.html b/test/fixtures/content/en/uk/page-metadata-language-support.html new file mode 100644 index 00000000..9e60c9da --- /dev/null +++ b/test/fixtures/content/en/uk/page-metadata-language-support.html @@ -0,0 +1,32 @@ + + + Home | Helix Project Boilerplate + + + + + + + + + + + + + + + + + + + +
+
+
+

HTML lang test

+

This is great.

+
+
+ + + \ No newline at end of file diff --git a/test/fixtures/content/en/uk/page-metadata-language-support.md b/test/fixtures/content/en/uk/page-metadata-language-support.md new file mode 100644 index 00000000..ebe144dc --- /dev/null +++ b/test/fixtures/content/en/uk/page-metadata-language-support.md @@ -0,0 +1,9 @@ +# HTML lang test + +This is great. + ++-------------------------------------------------------------------------------------------+ +| Metadata | ++================+==========================================================================+ +| title | Home \| Helix Project Boilerplate | ++----------------+--------------------------------------------------------------------------+ diff --git a/test/fixtures/content/page-metadata-language-support.html b/test/fixtures/content/page-metadata-language-support.html new file mode 100644 index 00000000..ebaea24d --- /dev/null +++ b/test/fixtures/content/page-metadata-language-support.html @@ -0,0 +1,32 @@ + + + Home | Helix Project Boilerplate + + + + + + + + + + + + + + + + + + + +
+
+
+

HTML lang test

+

This is great.

+
+
+ + + \ No newline at end of file diff --git a/test/fixtures/content/page-metadata-language-support.md b/test/fixtures/content/page-metadata-language-support.md new file mode 100644 index 00000000..ebe144dc --- /dev/null +++ b/test/fixtures/content/page-metadata-language-support.md @@ -0,0 +1,9 @@ +# HTML lang test + +This is great. + ++-------------------------------------------------------------------------------------------+ +| Metadata | ++================+==========================================================================+ +| title | Home \| Helix Project Boilerplate | ++----------------+--------------------------------------------------------------------------+ diff --git a/test/rendering.test.js b/test/rendering.test.js index c2136020..3a4d13bf 100644 --- a/test/rendering.test.js +++ b/test/rendering.test.js @@ -167,6 +167,33 @@ const DEFAULT_CONFIG_EMPTY = { }, }; +const CONFIG_LANGUAGE_SUPPORT = { + ...DEFAULT_CONFIG_EMPTY, + features: { + 'language-support': { + defaultLang: 'en-US', + langs: [ + { + lang: 'en-US', + prefix: '', + }, + { + lang: 'en-GB', + prefix: '/en/uk', + }, + { + lang: 'fr', + prefix: '/fr', + }, + { + lang: 'de-DE', + prefix: '/de', + }, + ], + }, + }, +}; + describe('Rendering', () => { let loader; let config; @@ -535,6 +562,16 @@ describe('Rendering', () => { config = DEFAULT_CONFIG_EMPTY; await testRender('page-metadata-htmllang-invalid', ':scope'); }); + + it('detects current lang and injects html lang and hreflang links based on feature config', async () => { + config = CONFIG_LANGUAGE_SUPPORT; + await testRender('en/uk/page-metadata-language-support', ':scope'); + }); + + it('detects current lang with empty prefix', async () => { + config = CONFIG_LANGUAGE_SUPPORT; + await testRender('page-metadata-language-support', ':scope'); + }); }); describe('Miscellaneous', () => { From d8bc8e4c5d149381291d82bc1aa3d3f9396bd244 Mon Sep 17 00:00:00 2001 From: rofe Date: Tue, 18 Mar 2025 16:10:18 +0100 Subject: [PATCH 2/3] chore: pr feedback and refactoring --- src/steps/render.js | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/steps/render.js b/src/steps/render.js index 9abd4a90..68b40843 100644 --- a/src/steps/render.js +++ b/src/steps/render.js @@ -41,13 +41,29 @@ function sanitizeJsonLd(jsonLd) { return JSON.stringify(JSON.parse(sanitizedJsonLd.trim()), null, 2); } +function getCurrentLang(path, langs) { + // find the longest matching prefix + const { lang: currentLang, prefix: currentPrefix } = langs.reduce((acc, lang) => { + const { prefix } = lang; + if ((path === prefix || path.startsWith(`${prefix}/`)) // path matches prefix + && (!acc || prefix.length > acc.prefix.length)) { // prefix is longer than previous + return lang; + } + return acc; + }); + return { + currentLang, + currentPrefix, + }; +} + function getLangHref(path, currentPrefix, prefix, canonical) { - if (currentPrefix === prefix) { - // current prefix is identical to prefix -> canonical - return canonical; - } else if (!currentPrefix) { + if (!currentPrefix) { // current prefix empty -> prepend prefix return new URL(`${prefix}${path}`, canonical).href; + } else if (currentPrefix === prefix) { + // current prefix is identical to prefix -> canonical + return canonical; } else { // replace current prefix with prefix return new URL(path.replace(currentPrefix, prefix), canonical).href; @@ -131,15 +147,9 @@ export default async function render(state, req, res) { // language support const { langs, defaultLang } = state.config.features?.['language-support'] || {}; - if (langs) { + if (Array.isArray(langs)) { const path = state.info.originalPath; - // find lang with longest matching prefix - const { lang: currentLang, prefix: currentPrefix } = langs.reduce((acc, lang) => { - if (path.startsWith(`${lang.prefix}/`) && (!acc || lang.prefix.length > acc.prefix.length)) { - return lang; - } - return acc; - }); + const { currentLang, currentPrefix } = getCurrentLang(path, langs); if (currentLang) { // set html lang if not already set via metadata if (!htmlLang) { From 9fc4c65d9bfdaaa2fa806a10887a6866b94cb45a Mon Sep 17 00:00:00 2001 From: rofe Date: Wed, 19 Mar 2025 11:20:56 +0100 Subject: [PATCH 3/3] chore: use canoncial path --- src/steps/render.js | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/steps/render.js b/src/steps/render.js index 68b40843..150ba259 100644 --- a/src/steps/render.js +++ b/src/steps/render.js @@ -41,11 +41,12 @@ function sanitizeJsonLd(jsonLd) { return JSON.stringify(JSON.parse(sanitizedJsonLd.trim()), null, 2); } -function getCurrentLang(path, langs) { +function getCurrentLang(canonical, langs) { + const { pathname } = canonical; // find the longest matching prefix const { lang: currentLang, prefix: currentPrefix } = langs.reduce((acc, lang) => { const { prefix } = lang; - if ((path === prefix || path.startsWith(`${prefix}/`)) // path matches prefix + if ((pathname === prefix || pathname.startsWith(`${prefix}/`)) // path matches prefix && (!acc || prefix.length > acc.prefix.length)) { // prefix is longer than previous return lang; } @@ -57,16 +58,19 @@ function getCurrentLang(path, langs) { }; } -function getLangHref(path, currentPrefix, prefix, canonical) { - if (!currentPrefix) { - // current prefix empty -> prepend prefix - return new URL(`${prefix}${path}`, canonical).href; - } else if (currentPrefix === prefix) { +function getLangHref(canonical, currentPrefix, prefix) { + if (currentPrefix === prefix) { // current prefix is identical to prefix -> canonical - return canonical; + return canonical.href; } else { - // replace current prefix with prefix - return new URL(path.replace(currentPrefix, prefix), canonical).href; + const { pathname } = canonical; + if (!currentPrefix) { + // current prefix empty -> prepend prefix + return new URL(`${prefix}${pathname}`, canonical).href; + } else { + // replace current prefix with prefix + return new URL(pathname.replace(currentPrefix, prefix), canonical).href; + } } } @@ -146,10 +150,10 @@ export default async function render(state, req, res) { } // language support - const { langs, defaultLang } = state.config.features?.['language-support'] || {}; + const { langs, defaultLang } = state.config?.features?.['language-support'] || {}; if (Array.isArray(langs)) { - const path = state.info.originalPath; - const { currentLang, currentPrefix } = getCurrentLang(path, langs); + const canonicalUrl = new URL(meta.canonical); + const { currentLang, currentPrefix } = getCurrentLang(canonicalUrl, langs); if (currentLang) { // set html lang if not already set via metadata if (!htmlLang) { @@ -157,14 +161,14 @@ export default async function render(state, req, res) { } // inject hreflang links langs.forEach(({ lang, prefix }) => { - const href = getLangHref(path, currentPrefix, prefix, meta.canonical); + const href = getLangHref(canonicalUrl, currentPrefix, prefix); $head.children.push(createElement('link', 'rel', 'alternate', 'hreflang', lang, 'href', href)); }); // write x-default hreflang link if default lang exists if (defaultLang) { const { lang, prefix } = langs.find((l) => l.lang === defaultLang); if (lang) { - const href = getLangHref(path, currentPrefix, prefix, meta.canonical); + const href = getLangHref(canonicalUrl, currentPrefix, prefix); $head.children.push(createElement('link', 'rel', 'alternate', 'hreflang', 'x-default', 'href', href)); } }