Skip to content

Commit e072b77

Browse files
authored
Merge pull request #56 from KTH/feat/language-link-helper
Feat: add handlebars language link helper
2 parents f0e614c + 2051653 commit e072b77

File tree

4 files changed

+296
-0
lines changed

4 files changed

+296
-0
lines changed

README.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,95 @@ registerBreadcrumbHelper()
4242
res.render(breadcrumbsPath: [{url: 'https://kth.se', label: 'KTH'}, ...], ...)
4343
```
4444

45+
## Language Link Helper
46+
47+
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.
48+
49+
### Register helper
50+
51+
```javascript
52+
// Typically server/views/helpers/index.js
53+
54+
const { registerLanguageLinkHelper } = require('@kth/kth-node-web-common/lib/handlebars/helpers/languageLink')
55+
56+
registerLanguageLinkHelper()
57+
```
58+
59+
### Add language constants
60+
61+
All language constants are optional.
62+
63+
- `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_
64+
- `language_link_button_close`: The label for the close button in the dialog element. Only used if there’s a dialog.
65+
- `language_link_not_translated`: The label for the dialog element's text. Only used if there’s a dialog.
66+
67+
```javascript
68+
// Example in i18n/messages.se.js
69+
70+
language_link_lang_en: 'English',
71+
language_link_not_translated: 'Den här sidan är ej översatt',
72+
language_link_button_close: 'Stäng',
73+
```
74+
75+
### Styling
76+
77+
Make sure to include styling from KTH Style.
78+
79+
```css
80+
/* Typically application’s main Sass-file */
81+
82+
@use '~@kth/style/scss/components/translation-panel';
83+
```
84+
85+
### Initialize menu panel
86+
87+
Make sure to initialize the menu panel (dialog). _This might be moved to KTH Style._
88+
89+
```javascript
90+
// Typically in server/views/layouts/publicLayout.handlebars
91+
92+
<script type="module">
93+
import {MenuPanel} from '{{ proxyPrefix }}/assets/js/index.js' MenuPanel.initTranslationModal(
94+
document.querySelector(".kth-menu-item.language"), document.querySelector(".kth-translation") )
95+
</script>
96+
```
97+
98+
### Use handlebars helper in head
99+
100+
Include the handlebars helper in the template.
101+
102+
```handlebars
103+
<!-- Typically in server/views/partials/kthHeader.handlebars -->
104+
105+
{{{languageLink lang}}}
106+
```
107+
108+
If no translated page exists, a dialog should be shown on link clink. This can be achieved by passing additional arguments to the helper.
109+
110+
1. The first argument is `lang`, the current language. It is required.
111+
2. The second argument is `anchorMessageKey`, the i18n key for the anchor element's text. Can be omitted (or pass `null`) for default label.
112+
3. The third argument is `link`, the URL to navigate to when the anchor is clicked. If provided, a dialog element is also generated.
113+
4. The fourth argument is `dialogMessageKey`, the i18n key for the dialog element's text. Required if `link` is provided.
114+
115+
```handlebars
116+
<!-- Typically in server/views/partials/kthHeader.handlebars -->
117+
118+
{{{languageLink lang anchorMessageKey link dialogMessageKey}}}
119+
```
120+
121+
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.
122+
123+
### Common use case
124+
125+
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:
126+
127+
1. Include the style from KTH Style, `@use '~@kth/style/scss/components/translation-panel';`
128+
2. Include the handlebars helper in the header partials template, `{{{languageLink lang}}}`
129+
3. Add `language_link_lang_sv: 'Svenska'` and `language_link_lang_en: 'English'` to `messages.en.js` and `messages.sv.js` respectively
130+
4. Verify that `lang` is passed to `render` in the controller.
131+
132+
A link to the opposite language page will now appear in the head.
133+
45134
## Cortina Blocks
46135

47136
Express middleware to fetch Cortina CMS blocks for requests with layouts requiring them:
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`languageLink should return dialog with custom link and default to opposite language label (en -> sv) 1`] = `
4+
"<a class="kth-menu-item language" hreflang="sv-SE" href="">Svenska</a>
5+
<dialog class="kth-translation">
6+
<div class="kth-translation__content">
7+
<button class="kth-icon-button close">
8+
<span class="kth-visually-hidden">Close</span>
9+
</button>
10+
<span>Den här sidan är inte översatt</span>
11+
<a href="https://kth.se/sv">Startsida på svenska</a>
12+
</div>
13+
</dialog>"
14+
`;
15+
16+
exports[`languageLink should return dialog with custom link and default to opposite language label (sv -> en) 1`] = `
17+
"<a class="kth-menu-item language" hreflang="en-US" href="">English</a>
18+
<dialog class="kth-translation">
19+
<div class="kth-translation__content">
20+
<button class="kth-icon-button close">
21+
<span class="kth-visually-hidden">Stäng</span>
22+
</button>
23+
<span>This page is not translated</span>
24+
<a href="https://kth.se/en">Start page in English</a>
25+
</div>
26+
</dialog>"
27+
`;
28+
29+
exports[`languageLink should return link with query parameter and anchorMessageKey in opposite language (en -> sv) 1`] = `"<a class="kth-menu-item language" hreflang="sv-SE" href="?l=sv">Custom</a>"`;
30+
31+
exports[`languageLink should return link with query parameter and anchorMessageKey in opposite language (sv -> en) 1`] = `"<a class="kth-menu-item language" hreflang="en-US" href="?l=en">Anpassad</a>"`;
32+
33+
exports[`languageLink should return link with query parameter and default to opposite language label (en -> sv) 1`] = `"<a class="kth-menu-item language" hreflang="sv-SE" href="?l=sv">Svenska</a>"`;
34+
35+
exports[`languageLink should return link with query parameter and default to opposite language label (sv -> en) 1`] = `"<a class="kth-menu-item language" hreflang="en-US" href="?l=en">English</a>"`;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
const Handlebars = require('handlebars')
2+
const i18n = require('kth-node-i18n')
3+
4+
const VALID_LANGUAGES = ['sv', 'en']
5+
const VALID_LOCALE = { sv: 'sv-SE', en: 'en-US' }
6+
7+
function resolveTranslationLanguage(lang) {
8+
if (!VALID_LANGUAGES.includes(lang)) {
9+
throw new Error(`[languageLink] helper requires first parameter to be a string matching a language, i.e. 'sv'.`)
10+
}
11+
return VALID_LANGUAGES[1 - VALID_LANGUAGES.indexOf(lang)]
12+
}
13+
14+
function resolveDefaultLabel(lang) {
15+
const translationLang = resolveTranslationLanguage(lang)
16+
return i18n.message(`language_link_lang_${translationLang}`, lang)
17+
}
18+
19+
function anchorElement(lang, anchorMessageKey, link) {
20+
const label = typeof anchorMessageKey === 'string' ? i18n.message(anchorMessageKey, lang) : resolveDefaultLabel(lang)
21+
22+
const hreflang = VALID_LOCALE[resolveTranslationLanguage(lang)]
23+
const output = `<a class="kth-menu-item language" hreflang="${hreflang}" href="${link}">${label}</a>`
24+
return output
25+
}
26+
27+
function dialogElement(lang, link, dialogMessageKey) {
28+
const output = `
29+
<dialog class="kth-translation">
30+
<div class="kth-translation__content">
31+
<button class="kth-icon-button close">
32+
<span class="kth-visually-hidden">${i18n.message('language_link_button_close', lang)}</span>
33+
</button>
34+
<span>${i18n.message('language_link_not_translated', lang)}</span>
35+
<a href="${link}">${i18n.message(dialogMessageKey, lang)}</a>
36+
</div>
37+
</dialog>`
38+
return output
39+
}
40+
41+
/**
42+
* Generates a language link and an optional dialog element for language selection.
43+
*
44+
* Used i18n keys:
45+
* - `language_link_lang_[sv/en]` - Default label for the anchor element's text, if a custom one isn’t provided.
46+
* - `language_link_button_close` - The label for the close button in the dialog element.
47+
* - `language_link_not_translated` - The label for the dialog element's text.
48+
*
49+
* @param {string} lang - The current language.
50+
* @param {string} [anchorMessageKey] - The i18n key for the anchor element's text. Can be omitted for default label.
51+
* @param {string} [link] - The URL to navigate to when the anchor is clicked. If provided, a dialog element is also generated.
52+
* @param {string} [dialogMessageKey] - The i18n key for the dialog element's text. Required if `link` is provided.
53+
*
54+
* @returns {string} The generated HTML string containing the language link and optional dialog element.
55+
*
56+
* @throws {Error} If `lang` is not a valid language or if `link` is provided but `dialogMessageKey` is not.
57+
*/
58+
function languageLink(lang, anchorMessageKey, link, dialogMessageKey) {
59+
// Custom link is missing, use a query parameter to change language
60+
if (typeof link !== 'string') {
61+
return anchorElement(lang, anchorMessageKey, `?l=${resolveTranslationLanguage(lang)}`)
62+
}
63+
64+
// Link is provided, but dialog information is incomplete
65+
if (typeof dialogMessageKey !== 'string') {
66+
throw new Error(`[languageLink] helper requires a fourth parameter, if a third is provided.`)
67+
}
68+
69+
// Link is provided, use custom link and dialog
70+
return `${anchorElement(lang, anchorMessageKey, '')}${dialogElement(lang, link, dialogMessageKey)}`
71+
}
72+
73+
function registerLanguageLinkHelper() {
74+
Handlebars.registerHelper('languageLink', languageLink)
75+
}
76+
77+
module.exports = {
78+
registerLanguageLinkHelper,
79+
languageLink, // Exported for testing
80+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
const handlebars = require('handlebars')
2+
3+
const mockTranslations = {
4+
en: {
5+
language_link_lang_sv: 'Svenska',
6+
label_custom: 'Custom',
7+
label_locale_select_link_title: 'Visa översättning',
8+
language_link_button_close: 'Close',
9+
language_link_not_translated: 'Den här sidan är inte översatt',
10+
label_start_page: 'Startsida på svenska',
11+
},
12+
sv: {
13+
language_link_lang_en: 'English',
14+
label_custom: 'Anpassad',
15+
label_locale_select_link_title: 'Show translation',
16+
language_link_button_close: 'Stäng',
17+
language_link_not_translated: 'This page is not translated',
18+
label_start_page: 'Start page in English',
19+
},
20+
}
21+
22+
jest.mock('kth-node-i18n', () => ({
23+
message: (key, lang) => mockTranslations[lang][key],
24+
}))
25+
26+
const { registerLanguageLinkHelper, languageLink } = require('./languageLink')
27+
28+
jest.mock('handlebars')
29+
30+
describe('registerLanguageLinkHelper', () => {
31+
it('registers language link helper', () => {
32+
registerLanguageLinkHelper()
33+
expect(handlebars.registerHelper).toHaveBeenCalledWith('languageLink', languageLink)
34+
})
35+
})
36+
37+
describe('languageLink', () => {
38+
it('throws an error if lang parameter is missing', () => {
39+
expect(() => languageLink()).toThrow(
40+
new Error(`[languageLink] helper requires first parameter to be a string matching a language, i.e. 'sv'.`)
41+
)
42+
})
43+
44+
it('throws an error if link is provided, but not dialogMessageKey', () => {
45+
const lang = 'en'
46+
expect(() => languageLink(lang, '', 'https://kth.se')).toThrow(
47+
new Error(`[languageLink] helper requires a fourth parameter, if a third is provided.`)
48+
)
49+
})
50+
51+
it('should return link with query parameter and default to opposite language label (en -> sv)', () => {
52+
const lang = 'en'
53+
const result = languageLink(lang)
54+
expect(result).toMatchSnapshot()
55+
})
56+
57+
it('should return link with query parameter and default to opposite language label (sv -> en)', () => {
58+
const lang = 'sv'
59+
const result = languageLink(lang)
60+
expect(result).toMatchSnapshot()
61+
})
62+
63+
it('should return link with query parameter and anchorMessageKey in opposite language (en -> sv)', () => {
64+
const lang = 'en'
65+
const anchorMessageKey = 'label_custom'
66+
const result = languageLink(lang, anchorMessageKey)
67+
expect(result).toMatchSnapshot()
68+
})
69+
70+
it('should return link with query parameter and anchorMessageKey in opposite language (sv -> en)', () => {
71+
const lang = 'sv'
72+
const anchorMessageKey = 'label_custom'
73+
const result = languageLink(lang, anchorMessageKey)
74+
expect(result).toMatchSnapshot()
75+
})
76+
77+
it('should return dialog with custom link and default to opposite language label (en -> sv)', () => {
78+
const lang = 'en'
79+
const link = 'https://kth.se/sv'
80+
const dialogMessageKey = 'label_start_page'
81+
const result = languageLink(lang, null, link, dialogMessageKey)
82+
expect(result).toMatchSnapshot()
83+
})
84+
85+
it('should return dialog with custom link and default to opposite language label (sv -> en)', () => {
86+
const lang = 'sv'
87+
const link = 'https://kth.se/en'
88+
const dialogMessageKey = 'label_start_page'
89+
const result = languageLink(lang, null, link, dialogMessageKey)
90+
expect(result).toMatchSnapshot()
91+
})
92+
})

0 commit comments

Comments
 (0)