diff --git a/components/lib/copy-code.ts b/components/lib/copy-code.ts index 8b0f3793c1..b6a7ddefe7 100644 --- a/components/lib/copy-code.ts +++ b/components/lib/copy-code.ts @@ -5,7 +5,11 @@ export default function copyCode() { buttons.forEach((button) => button.addEventListener('click', async () => { - const text = (button as HTMLElement).dataset.clipboardText + const codeId = (button as HTMLElement).dataset.clipboard + if (!codeId) return + const pre = document.querySelector(`pre[data-clipboard="${codeId}"]`) as HTMLElement | null + if (!pre) return + const text = pre.innerText if (!text) return await navigator.clipboard.writeText(text) diff --git a/components/lib/toggle-annotations.ts b/components/lib/toggle-annotations.ts new file mode 100644 index 0000000000..97b3e93ebe --- /dev/null +++ b/components/lib/toggle-annotations.ts @@ -0,0 +1,90 @@ +import Cookies from 'components/lib/cookies' + +enum annotationMode { + Beside = '#annotation-beside', + Inline = '#annotation-inline', +} + +/** + * Validates if a given mode is one of expected annotation modes. If no acceptable mode is found, a default mode is returned.. Optionally, returns a default mode. + * @param mode The mode to validate, ideally "#annotation-beside" or "#annotation-inline" + * @param leaveNull Alters the return value of this function. If false, the function will return the mode that was passed in or, in the case of null, the default mode. If true, the function will return null instead of using the default mode. + * @returns The validated mode, or null if leaveNull is true and no valid mode is found. + */ +function validateMode(mode?: string, leaveNull?: boolean) { + if (mode === annotationMode.Beside || mode === annotationMode.Inline || (!mode && leaveNull)) + return mode + else { + if (leaveNull) { + console.warn(`Leaving null.`) + return + } + + // default to beside + return annotationMode.Beside + } +} + +export default function toggleAnnotation() { + const subNavElements = Array.from(document.querySelectorAll('a.subnav-item')) + if (!subNavElements.length) return + + const cookie = validateMode(Cookies.get('annotate-mode')) // will default to beside + displayAnnotationMode(setActive(subNavElements, cookie), subNavElements, cookie) + + // this loop adds event listeners for both the annotation buttons + for (const subnav of subNavElements) { + subnav.addEventListener('click', (evt) => { + evt.preventDefault() + + // returns either: + // 1. if href is correct, the href that was passed in + // 2. if href is missing, null + const validMode = validateMode(subnav.getAttribute('href')!) + + Cookies.set('annotate-mode', validMode!) + + getActive(subNavElements).removeAttribute('aria-current') + setActive(subNavElements, validMode) + displayAnnotationMode(subnav, subNavElements, validMode) + }) + } +} + +// returns the active element via its aria-current attribute, errors if it can't find it +function getActive(subnavItems: Array) { + const currentlyActive = subnavItems.find((el) => el.ariaCurrent === 'true') + + if (!currentlyActive) setActive(subnavItems) + + return currentlyActive! +} + +// sets the active element's aria-current, if no targetMode is set we default to "Beside", errors if it can't set either Beside or the passed in targetMode +function setActive(subNavElements: Array, targetMode?: string) { + targetMode = validateMode(targetMode) + const targetActiveElement = subNavElements.find((el) => el.getAttribute('href') === targetMode) + + if (!targetActiveElement) { + throw new Error('No subnav item is active for code annotation.') + } + + targetActiveElement.ariaCurrent = 'true' + + return targetActiveElement +} + +// displays the chosen annotation mode +function displayAnnotationMode( + activeElement: Element, + subNavItems: Array, + targetMode?: string +) { + if (!targetMode || targetMode === annotationMode.Beside) + activeElement.closest('.annotate')?.classList.replace('inline', 'beside') + else if (targetMode === annotationMode.Inline) + activeElement.closest('.annotate')?.classList.replace('beside', 'inline') + else throw new Error('Invalid target mode set for annotation.') + + setActive(subNavItems, targetMode) +} diff --git a/lib/render-content/plugins/annotate.js b/lib/render-content/plugins/annotate.js index 89d9a99e09..3ee07936ef 100644 --- a/lib/render-content/plugins/annotate.js +++ b/lib/render-content/plugins/annotate.js @@ -117,29 +117,55 @@ function matchComment(lang) { return (line) => regex.test(line) } +function getSubnav() { + const besideBtn = h( + 'a', + { + className: 'subnav-item', + href: '#annotation-beside', + }, + ['Beside'] + ) + const inlineBtn = h( + 'a', + { + className: 'subnav-item', + href: '#annotation-inline', + }, + ['Inline'] + ) + + return h('nav', { className: 'subnav mb-0 pr-2' }, [besideBtn, inlineBtn]) +} + function template({ lang, code, rows }) { return h( 'div', - { class: 'annotate' }, - h('div', { className: 'annotate-row header' }, [ - h('div', { className: 'annotate-code header color-bg-default' }, header(lang, code)), - h('div', { className: 'annotate-note header' }), - ]), - rows.map(([note, code]) => - h('div', { className: 'annotate-row' }, [ - h( - 'div', - { className: 'annotate-code' }, - // This tree matches the mdast -> hast tree of a regular fenced code block. - h('pre', h('code', { className: `language-${lang}` }, code.join('\n'))) - ), - h( - 'div', - { className: 'annotate-note' }, - mdToHast(note.map(removeComment(lang)).join('\n')) - ), - ]) - ) + { class: 'annotate beside' }, + h('div', { className: 'annotate-header' }, header(lang, code, getSubnav())), + h( + 'div', + { className: 'annotate-beside' }, + rows.map(([note, code]) => + h('div', { className: 'annotate-row' }, [ + h( + 'div', + { className: 'annotate-code' }, + // pre > code matches the mdast -> hast tree of a regular fenced code block. + h('pre', h('code', { className: `language-${lang}` }, code.join('\n'))) + ), + h( + 'div', + { className: 'annotate-note' }, + mdToHast(note.map(removeComment(lang)).join('\n')) + ), + ]) + ) + ), + h('div', { className: 'annotate-inline' }, [ + // pre > code matches the mdast -> hast tree of a regular fenced code block. + h('pre', h('code', { className: `language-${lang}` }, code)), + ]) ) } diff --git a/lib/render-content/plugins/code-header.js b/lib/render-content/plugins/code-header.js index b005a040d0..ec79a21de1 100644 --- a/lib/render-content/plugins/code-header.js +++ b/lib/render-content/plugins/code-header.js @@ -9,6 +9,7 @@ import { h } from 'hastscript' import octicons from '@primer/octicons' import { parse } from 'parse5' import { fromParse5 } from 'hast-util-from-parse5' +import murmur from 'imurmurhash' const languages = yaml.load(fs.readFileSync('./data/variables/code-languages.yml', 'utf8')) @@ -35,7 +36,8 @@ function wrapCodeExample(node) { return h('div', { className: 'code-example' }, [header(lang, code), node]) } -export function header(lang, code) { +export function header(lang, code, subnav) { + const codeId = murmur('js-btn-copy').hash(code).result() return h( 'header', { @@ -52,16 +54,18 @@ export function header(lang, code) { ], }, [ - h('span', languages[lang]?.name), + h('span', { className: 'flex-1' }, languages[lang]?.name), + subnav, h( 'button', { class: ['js-btn-copy', 'btn', 'btn-sm', 'tooltipped', 'tooltipped-nw'], - 'data-clipboard-text': code, 'aria-label': 'Copy code to clipboard', + 'data-clipboard': codeId, }, btnIcon() ), + h('pre', { hidden: true, 'data-clipboard': codeId }, code), ] ) } diff --git a/middleware/detect-annotation-preference.js b/middleware/detect-annotation-preference.js new file mode 100644 index 0000000000..9a5b8041b5 --- /dev/null +++ b/middleware/detect-annotation-preference.js @@ -0,0 +1,68 @@ +import languages, { languageKeys } from '../lib/languages.js' +import parser from 'accept-language-parser' + +import { USER_LANGUAGE_COOKIE_NAME } from '../lib/constants.js' + +const chineseRegions = [ + 'CN', // Mainland + 'HK', // Hong Kong + 'SG', // Singapore + 'TW', // Taiwan +] + +function translationExists(language) { + if (language.code === 'zh') { + return chineseRegions.includes(language.region) + } + // 92BD1212-61B8-4E7A: Remove ` && !languages[language.code].wip` for the public ship of ko, fr, de, ru + return languageKeys.includes(language.code) && !languages[language.code].wip +} + +function getLanguageCode(language) { + return language.code === 'cn' && chineseRegions.includes(language.region) ? 'zh' : language.code +} + +function getUserLanguage(browserLanguages) { + try { + let numTopPreferences = 1 + for (let lang = 0; lang < browserLanguages.length; lang++) { + // If language has multiple regions, Chrome adds the non-region language to list + if (lang > 0 && browserLanguages[lang].code !== browserLanguages[lang - 1].code) + numTopPreferences++ + if (translationExists(browserLanguages[lang]) && numTopPreferences < 3) { + return getLanguageCode(browserLanguages[lang]) + } + } + } catch { + return undefined + } +} + +function getUserLanguageFromCookie(req) { + const value = req.cookies[USER_LANGUAGE_COOKIE_NAME] + // 92BD1212-61B8-4E7A: Remove ` && !languages[value].wip` for the public ship of ko, fr, de, ru + if (value && languages[value] && !languages[value].wip) { + return value + } +} + +// determine language code from a path. Default to en if no valid match +export function getLanguageCodeFromPath(path) { + const maybeLanguage = (path.split('/')[path.startsWith('/_next/data/') ? 4 : 1] || '').slice(0, 2) + return languageKeys.includes(maybeLanguage) ? maybeLanguage : 'en' +} + +export function getLanguageCodeFromHeader(req) { + const browserLanguages = parser.parse(req.headers['accept-language']) + return getUserLanguage(browserLanguages) +} + +export default function detectLanguage(req, res, next) { + req.language = getLanguageCodeFromPath(req.path) + // Detecting browser language by user preference + req.userLanguage = getUserLanguageFromCookie(req) + if (!req.userLanguage) { + req.userLanguage = getLanguageCodeFromHeader(req) + } + return next() +} diff --git a/src/landings/pages/product.tsx b/src/landings/pages/product.tsx index f76efc5e5a..1673a5223d 100644 --- a/src/landings/pages/product.tsx +++ b/src/landings/pages/product.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/router' // "legacy" javascript needed to maintain existing functionality // typically operating on elements **within** an article. import copyCode from 'components/lib/copy-code' +import toggleAnnotation from 'components/lib/toggle-annotations' import localization from 'components/lib/localization' import wrapCodeTerms from 'components/lib/wrap-code-terms' @@ -41,6 +42,7 @@ function initiateArticleScripts() { copyCode() localization() wrapCodeTerms() + toggleAnnotation() } type Props = { diff --git a/stylesheets/utilities.scss b/stylesheets/utilities.scss index 6b49893a8d..495ed9718e 100644 --- a/stylesheets/utilities.scss +++ b/stylesheets/utilities.scss @@ -131,43 +131,41 @@ ----------------------------------------------------------------------------*/ $annotation-breakpoint: 1550px; -.annotate { - margin: 0 -250px; - - @media (max-width: $annotation-breakpoint) { - margin: auto; +.annotate.beside { + .annotate-beside { + display: inherit; } - - & > div:last-child > .annotate-code { - border-bottom-left-radius: 6px; - border-bottom-right-radius: 6px; - border-bottom: 1px solid var(--color-border-default); + .annotate-inline { + display: none; } } -.annotate-row { - display: flex; - flex-direction: column; +.annotate.inline { + .annotate-beside { + display: none; + } + .annotate-inline { + display: inherit; + } +} + +.beside .annotate-header { + margin: auto; + + header { + width: inherit; + } @media (min-width: $annotation-breakpoint) { - flex-direction: row; - margin: 0 auto; + margin: 0 -250px; - &:not(.header):hover { - border-radius: 4px; - outline: 2px solid var(--color-accent-fg); + header { + width: 50%; } } } -.annotate-code { - background-color: var(--color-canvas-subtle); - - &:not(.header) { - border-left: 1px solid var(--color-border-default); - border-right: 1px solid var(--color-border-default); - } - +.annotate { pre { border: 0px !important; margin: 0px !important; @@ -181,6 +179,51 @@ $annotation-breakpoint: 1550px; word-wrap: break-word; } } +} + +div.annotate-header > header > nav > a { + text-decoration: none; +} + +.annotate-beside { + margin: auto; + + @media (min-width: $annotation-breakpoint) { + margin: 0 -250px; + } + + & > div:last-child > .annotate-code { + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + border-bottom: 1px solid var(--color-border-default); + } +} + +.annotate-inline { + border-left: 1px solid var(--color-border-default); + border-right: 1px solid var(--color-border-default); + border-bottom: 1px solid var(--color-border-default); +} + +.annotate-row { + display: flex; + flex-direction: column; + + @media (min-width: $annotation-breakpoint) { + flex-direction: row; + margin: 0 auto; + + &:hover { + border-radius: 4px; + outline: 2px solid var(--color-accent-fg); + } + } +} + +.annotate-code { + background-color: var(--color-canvas-subtle); + border-left: 1px solid var(--color-border-default); + border-right: 1px solid var(--color-border-default); @media (min-width: $annotation-breakpoint) { width: 50%; @@ -194,11 +237,4 @@ $annotation-breakpoint: 1550px; padding: 16px 0 16px 16px; font-size: 14px; } - - @media (max-width: $annotation-breakpoint) { - .annotate-note { - padding: 16px 0 32px 0px; - font-size: 14px; - } - } } diff --git a/tests/rendering/__snapshots__/annotate.js.snap b/tests/rendering/__snapshots__/annotate.js.snap index 6bbd081a68..d683ba1185 100644 --- a/tests/rendering/__snapshots__/annotate.js.snap +++ b/tests/rendering/__snapshots__/annotate.js.snap @@ -1,16 +1,24 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`annotate renders annotations 1`] = ` -"
YAML
name: Post welcome comment

The name of the workflow as it will appear in the "Actions" tab of the GitHub repository.

on:
+
name: Post welcome comment

The name of the workflow as it will appear in the "Actions" tab of the GitHub repository.

on:
   pull_request:
     types: [opened]

Add the pull_request event, so that the workflow runs automatically -every time a pull request is created.

" +every time a pull request is created.

# The name of the workflow as it will appear in the "Actions" tab of the GitHub repository.
+name: Post welcome comment
+
+# Add the \`pull_request\` event, so that the workflow runs automatically
+# every time a pull request is created.
+on:
+  pull_request:
+    types: [opened]
+
" `; diff --git a/tests/unit/render-content.js b/tests/unit/render-content.js index 98525ea3b6..e844ef7037 100644 --- a/tests/unit/render-content.js +++ b/tests/unit/render-content.js @@ -276,6 +276,7 @@ var a = 1 const html = await renderContent(template) const $ = cheerio.load(html) const el = $('button.js-btn-copy') - expect(el.data('clipboard-text')).toBe('var a = 1\n') + expect(el.data('clipboard')).toBe(2967273189) + // Generates a murmurhash based ID that matches a
   })
 })