diff --git a/README.md b/README.md
index 2b0047d..ae68e5c 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,95 @@ registerBreadcrumbHelper()
res.render(breadcrumbsPath: [{url: 'https://kth.se', label: 'KTH'}, ...], ...)
```
+## Language Link Helper
+
+Handlebars helper to generate a language link in the header. If language can’t be toggled with query parameter `l`, then there is an option to display a dialog with a custom link.
+
+### Register helper
+
+```javascript
+// Typically server/views/helpers/index.js
+
+const { registerLanguageLinkHelper } = require('@kth/kth-node-web-common/lib/handlebars/helpers/languageLink')
+
+registerLanguageLinkHelper()
+```
+
+### Add language constants
+
+All language constants are optional.
+
+- `language_link_lang_[en/sv]`: Default label for the anchor element's text, if a custom one isn’t provided. Remember that it should be displayed in the opposite language, e.g. if the page is in English the label should be _Svenska_
+- `language_link_button_close`: The label for the close button in the dialog element. Only used if there’s a dialog.
+- `language_link_not_translated`: The label for the dialog element's text. Only used if there’s a dialog.
+
+```javascript
+// Example in i18n/messages.se.js
+
+language_link_lang_en: 'English',
+language_link_not_translated: 'Den här sidan är ej översatt',
+language_link_button_close: 'Stäng',
+```
+
+### Styling
+
+Make sure to include styling from KTH Style.
+
+```css
+/* Typically application’s main Sass-file */
+
+@use '~@kth/style/scss/components/translation-panel';
+```
+
+### Initialize menu panel
+
+Make sure to initialize the menu panel (dialog). _This might be moved to KTH Style._
+
+```javascript
+// Typically in server/views/layouts/publicLayout.handlebars
+
+
+```
+
+### Use handlebars helper in head
+
+Include the handlebars helper in the template.
+
+```handlebars
+
+
+{{{languageLink lang}}}
+```
+
+If no translated page exists, a dialog should be shown on link clink. This can be achieved by passing additional arguments to the helper.
+
+1. The first argument is `lang`, the current language. It is required.
+2. The second argument is `anchorMessageKey`, the i18n key for the anchor element's text. Can be omitted (or pass `null`) for default label.
+3. The third argument is `link`, the URL to navigate to when the anchor is clicked. If provided, a dialog element is also generated.
+4. The fourth argument is `dialogMessageKey`, the i18n key for the dialog element's text. Required if `link` is provided.
+
+```handlebars
+
+
+{{{languageLink lang anchorMessageKey link dialogMessageKey}}}
+```
+
+Use any variable names, only the argument order matters. Remember that they don’t have to have values. The full signature can be used in the handlebars template, with values only being set in the controller when non-default behavior is needed.
+
+### Common use case
+
+The most common use case is probably that a translated page can be reached by simply adding the query parameter `l`, with a language key like `en`. To achieve this, follow these steps:
+
+1. Include the style from KTH Style, `@use '~@kth/style/scss/components/translation-panel';`
+2. Include the handlebars helper in the header partials template, `{{{languageLink lang}}}`
+3. Add `language_link_lang_sv: 'Svenska'` and `language_link_lang_en: 'English'` to `messages.en.js` and `messages.sv.js` respectively
+4. Verify that `lang` is passed to `render` in the controller.
+
+A link to the opposite language page will now appear in the head.
+
## Cortina Blocks
Express middleware to fetch Cortina CMS blocks for requests with layouts requiring them:
diff --git a/lib/handlebars/helpers/__snapshots__/languageLink.test.js.snap b/lib/handlebars/helpers/__snapshots__/languageLink.test.js.snap
new file mode 100644
index 0000000..cf935e2
--- /dev/null
+++ b/lib/handlebars/helpers/__snapshots__/languageLink.test.js.snap
@@ -0,0 +1,35 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`languageLink should return dialog with custom link and default to opposite language label (en -> sv) 1`] = `
+"Svenska
+ "
+`;
+
+exports[`languageLink should return dialog with custom link and default to opposite language label (sv -> en) 1`] = `
+"English
+ "
+`;
+
+exports[`languageLink should return link with query parameter and anchorMessageKey in opposite language (en -> sv) 1`] = `"Custom"`;
+
+exports[`languageLink should return link with query parameter and anchorMessageKey in opposite language (sv -> en) 1`] = `"Anpassad"`;
+
+exports[`languageLink should return link with query parameter and default to opposite language label (en -> sv) 1`] = `"Svenska"`;
+
+exports[`languageLink should return link with query parameter and default to opposite language label (sv -> en) 1`] = `"English"`;
diff --git a/lib/handlebars/helpers/languageLink.js b/lib/handlebars/helpers/languageLink.js
new file mode 100644
index 0000000..29edd2b
--- /dev/null
+++ b/lib/handlebars/helpers/languageLink.js
@@ -0,0 +1,80 @@
+const Handlebars = require('handlebars')
+const i18n = require('kth-node-i18n')
+
+const VALID_LANGUAGES = ['sv', 'en']
+const VALID_LOCALE = { sv: 'sv-SE', en: 'en-US' }
+
+function resolveTranslationLanguage(lang) {
+ if (!VALID_LANGUAGES.includes(lang)) {
+ throw new Error(`[languageLink] helper requires first parameter to be a string matching a language, i.e. 'sv'.`)
+ }
+ return VALID_LANGUAGES[1 - VALID_LANGUAGES.indexOf(lang)]
+}
+
+function resolveDefaultLabel(lang) {
+ const translationLang = resolveTranslationLanguage(lang)
+ return i18n.message(`language_link_lang_${translationLang}`, lang)
+}
+
+function anchorElement(lang, anchorMessageKey, link) {
+ const label = typeof anchorMessageKey === 'string' ? i18n.message(anchorMessageKey, lang) : resolveDefaultLabel(lang)
+
+ const hreflang = VALID_LOCALE[resolveTranslationLanguage(lang)]
+ const output = `${label}`
+ return output
+}
+
+function dialogElement(lang, link, dialogMessageKey) {
+ const output = `
+ `
+ return output
+}
+
+/**
+ * Generates a language link and an optional dialog element for language selection.
+ *
+ * Used i18n keys:
+ * - `language_link_lang_[sv/en]` - Default label for the anchor element's text, if a custom one isn’t provided.
+ * - `language_link_button_close` - The label for the close button in the dialog element.
+ * - `language_link_not_translated` - The label for the dialog element's text.
+ *
+ * @param {string} lang - The current language.
+ * @param {string} [anchorMessageKey] - The i18n key for the anchor element's text. Can be omitted for default label.
+ * @param {string} [link] - The URL to navigate to when the anchor is clicked. If provided, a dialog element is also generated.
+ * @param {string} [dialogMessageKey] - The i18n key for the dialog element's text. Required if `link` is provided.
+ *
+ * @returns {string} The generated HTML string containing the language link and optional dialog element.
+ *
+ * @throws {Error} If `lang` is not a valid language or if `link` is provided but `dialogMessageKey` is not.
+ */
+function languageLink(lang, anchorMessageKey, link, dialogMessageKey) {
+ // Custom link is missing, use a query parameter to change language
+ if (typeof link !== 'string') {
+ return anchorElement(lang, anchorMessageKey, `?l=${resolveTranslationLanguage(lang)}`)
+ }
+
+ // Link is provided, but dialog information is incomplete
+ if (typeof dialogMessageKey !== 'string') {
+ throw new Error(`[languageLink] helper requires a fourth parameter, if a third is provided.`)
+ }
+
+ // Link is provided, use custom link and dialog
+ return `${anchorElement(lang, anchorMessageKey, '')}${dialogElement(lang, link, dialogMessageKey)}`
+}
+
+function registerLanguageLinkHelper() {
+ Handlebars.registerHelper('languageLink', languageLink)
+}
+
+module.exports = {
+ registerLanguageLinkHelper,
+ languageLink, // Exported for testing
+}
diff --git a/lib/handlebars/helpers/languageLink.test.js b/lib/handlebars/helpers/languageLink.test.js
new file mode 100644
index 0000000..99aa053
--- /dev/null
+++ b/lib/handlebars/helpers/languageLink.test.js
@@ -0,0 +1,92 @@
+const handlebars = require('handlebars')
+
+const mockTranslations = {
+ en: {
+ language_link_lang_sv: 'Svenska',
+ label_custom: 'Custom',
+ label_locale_select_link_title: 'Visa översättning',
+ language_link_button_close: 'Close',
+ language_link_not_translated: 'Den här sidan är inte översatt',
+ label_start_page: 'Startsida på svenska',
+ },
+ sv: {
+ language_link_lang_en: 'English',
+ label_custom: 'Anpassad',
+ label_locale_select_link_title: 'Show translation',
+ language_link_button_close: 'Stäng',
+ language_link_not_translated: 'This page is not translated',
+ label_start_page: 'Start page in English',
+ },
+}
+
+jest.mock('kth-node-i18n', () => ({
+ message: (key, lang) => mockTranslations[lang][key],
+}))
+
+const { registerLanguageLinkHelper, languageLink } = require('./languageLink')
+
+jest.mock('handlebars')
+
+describe('registerLanguageLinkHelper', () => {
+ it('registers language link helper', () => {
+ registerLanguageLinkHelper()
+ expect(handlebars.registerHelper).toHaveBeenCalledWith('languageLink', languageLink)
+ })
+})
+
+describe('languageLink', () => {
+ it('throws an error if lang parameter is missing', () => {
+ expect(() => languageLink()).toThrow(
+ new Error(`[languageLink] helper requires first parameter to be a string matching a language, i.e. 'sv'.`)
+ )
+ })
+
+ it('throws an error if link is provided, but not dialogMessageKey', () => {
+ const lang = 'en'
+ expect(() => languageLink(lang, '', 'https://kth.se')).toThrow(
+ new Error(`[languageLink] helper requires a fourth parameter, if a third is provided.`)
+ )
+ })
+
+ it('should return link with query parameter and default to opposite language label (en -> sv)', () => {
+ const lang = 'en'
+ const result = languageLink(lang)
+ expect(result).toMatchSnapshot()
+ })
+
+ it('should return link with query parameter and default to opposite language label (sv -> en)', () => {
+ const lang = 'sv'
+ const result = languageLink(lang)
+ expect(result).toMatchSnapshot()
+ })
+
+ it('should return link with query parameter and anchorMessageKey in opposite language (en -> sv)', () => {
+ const lang = 'en'
+ const anchorMessageKey = 'label_custom'
+ const result = languageLink(lang, anchorMessageKey)
+ expect(result).toMatchSnapshot()
+ })
+
+ it('should return link with query parameter and anchorMessageKey in opposite language (sv -> en)', () => {
+ const lang = 'sv'
+ const anchorMessageKey = 'label_custom'
+ const result = languageLink(lang, anchorMessageKey)
+ expect(result).toMatchSnapshot()
+ })
+
+ it('should return dialog with custom link and default to opposite language label (en -> sv)', () => {
+ const lang = 'en'
+ const link = 'https://kth.se/sv'
+ const dialogMessageKey = 'label_start_page'
+ const result = languageLink(lang, null, link, dialogMessageKey)
+ expect(result).toMatchSnapshot()
+ })
+
+ it('should return dialog with custom link and default to opposite language label (sv -> en)', () => {
+ const lang = 'sv'
+ const link = 'https://kth.se/en'
+ const dialogMessageKey = 'label_start_page'
+ const result = languageLink(lang, null, link, dialogMessageKey)
+ expect(result).toMatchSnapshot()
+ })
+})