From 75f90c9487bf01a32637293e6eddf1262fc41850 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 14 Jun 2021 11:07:39 -0700 Subject: [PATCH] Update current JavaScripts to use Typescript (#19824) * Update index to make it clear what has hasn't updated to Next/React yet * Typescriptify events and experiments * Typescript the old JS for easier integration * Update release-notes.ts * Lint * Run npm i * Fix a few lint issues * Update airgap-links.ts * Update airgap-links.ts * Update set-next-env to ts * Update airgap-links.ts * Update package-lock.json * Update set-next-env.ts * Update package-lock.json * Revert "Update package-lock.json" This reverts commit b45e8250beeb700719d3b44e1092b0bbd093baba. * readd fsevents * Revert "readd fsevents" This reverts commit 419f3c35080ac4a9072f0b4e8e291e1712ce3639. * Update openapi-schema-check.yml * Revert "Update openapi-schema-check.yml" This reverts commit 5e9f4a29ea11ee343ca17291a40a751920c5b923. * Update package-lock.json * Update package-lock.json --- components/Search.tsx | 4 +- components/Survey.tsx | 10 +- javascripts/airgap-links.js | 17 -- javascripts/airgap-links.ts | 17 ++ javascripts/all-articles.js | 18 -- javascripts/all-articles.ts | 17 ++ javascripts/browser-date-formatter.d.ts | 3 + javascripts/{copy-code.js => copy-code.ts} | 9 +- javascripts/{dev-toc.js => dev-toc.ts} | 22 +- .../display-platform-specific-content.js | 87 ------- .../display-platform-specific-content.ts | 91 +++++++ javascripts/display-tool-specific-content.js | 97 ------- javascripts/display-tool-specific-content.ts | 96 +++++++ javascripts/events.js | 246 ------------------ javascripts/events.ts | 224 ++++++++++++++++ javascripts/{experiment.js => experiment.ts} | 10 +- javascripts/explorer.js | 15 -- javascripts/explorer.ts | 16 ++ .../{filter-cards.js => filter-cards.ts} | 51 ++-- javascripts/get-csrf.js | 6 - javascripts/get-csrf.ts | 5 + .../{hyperscript.js => hyperscript.ts} | 20 +- javascripts/{index.js => index.ts} | 0 .../{localization.js => localization.ts} | 2 +- javascripts/{nav.js => nav.ts} | 0 javascripts/{print.js => print.ts} | 6 +- javascripts/release-notes.js | 19 -- javascripts/release-notes.ts | 26 ++ javascripts/{scroll-up.js => scroll-up.ts} | 4 +- javascripts/search-with-your-keyboard.d.ts | 3 + javascripts/{search.js => search.ts} | 181 +++++++------ .../{set-next-env.js => set-next-env.ts} | 3 +- javascripts/{show-more.js => show-more.ts} | 19 +- javascripts/{sidebar.js => sidebar.ts} | 14 +- javascripts/survey.js | 77 ------ javascripts/survey.ts | 69 +++++ .../{toggle-images.js => toggle-images.ts} | 43 +-- javascripts/{user-agent.js => user-agent.ts} | 16 +- ...{wrap-code-terms.js => wrap-code-terms.ts} | 24 +- package-lock.json | 159 +++++++++++ package.json | 4 + tsconfig.json | 18 +- webpack.config.js | 22 +- 43 files changed, 982 insertions(+), 808 deletions(-) delete mode 100644 javascripts/airgap-links.js create mode 100644 javascripts/airgap-links.ts delete mode 100644 javascripts/all-articles.js create mode 100644 javascripts/all-articles.ts create mode 100644 javascripts/browser-date-formatter.d.ts rename javascripts/{copy-code.js => copy-code.ts} (58%) rename javascripts/{dev-toc.js => dev-toc.ts} (60%) delete mode 100644 javascripts/display-platform-specific-content.js create mode 100644 javascripts/display-platform-specific-content.ts delete mode 100644 javascripts/display-tool-specific-content.js create mode 100644 javascripts/display-tool-specific-content.ts delete mode 100644 javascripts/events.js create mode 100644 javascripts/events.ts rename javascripts/{experiment.js => experiment.ts} (75%) delete mode 100644 javascripts/explorer.js create mode 100644 javascripts/explorer.ts rename javascripts/{filter-cards.js => filter-cards.ts} (67%) delete mode 100644 javascripts/get-csrf.js create mode 100644 javascripts/get-csrf.ts rename javascripts/{hyperscript.js => hyperscript.ts} (59%) rename javascripts/{index.js => index.ts} (100%) rename javascripts/{localization.js => localization.ts} (91%) rename javascripts/{nav.js => nav.ts} (100%) rename javascripts/{print.js => print.ts} (68%) delete mode 100644 javascripts/release-notes.js create mode 100644 javascripts/release-notes.ts rename javascripts/{scroll-up.js => scroll-up.ts} (87%) create mode 100644 javascripts/search-with-your-keyboard.d.ts rename javascripts/{search.js => search.ts} (59%) rename javascripts/{set-next-env.js => set-next-env.ts} (53%) rename javascripts/{show-more.js => show-more.ts} (70%) rename javascripts/{sidebar.js => sidebar.ts} (70%) delete mode 100644 javascripts/survey.js create mode 100644 javascripts/survey.ts rename javascripts/{toggle-images.js => toggle-images.ts} (73%) rename javascripts/{user-agent.js => user-agent.ts} (58%) rename javascripts/{wrap-code-terms.js => wrap-code-terms.ts} (54%) diff --git a/components/Search.tsx b/components/Search.tsx index 3e6a90a611..1aaa5df81f 100644 --- a/components/Search.tsx +++ b/components/Search.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, ReactNode } from 'react' import { useRouter } from 'next/router' import debounce from 'lodash/debounce' import { useTranslation } from 'components/hooks/useTranslation' -import { sendEvent } from '../javascripts/events' +import { sendEvent, EventType } from '../javascripts/events' import { useMainContext } from './context/MainContext' import { useVersion } from 'components/hooks/useVersion' @@ -126,7 +126,7 @@ export function Search({ isStandalone = false, updateSearchParams = true, childr // Analytics tracking if (xquery) { sendEvent({ - type: 'search', + type: EventType.search, search_query: xquery, // search_context }) diff --git a/components/Survey.tsx b/components/Survey.tsx index df209e63da..b4b81b2c12 100644 --- a/components/Survey.tsx +++ b/components/Survey.tsx @@ -2,7 +2,7 @@ import { useState, useRef } from 'react' import { ThumbsdownIcon, ThumbsupIcon } from '@primer/octicons-react' import { useTranslation } from 'components/hooks/useTranslation' import { Link } from 'components/Link' -import { sendEvent } from '../javascripts/events' +import { sendEvent, EventType } from '../javascripts/events' enum ViewState { START = 'START', @@ -137,10 +137,10 @@ function trackEvent(formData: FormData | undefined) { if (!formData) return // Nota bene: convert empty strings to undefined return sendEvent({ - type: 'survey', - survey_token: formData.get('survey-token') || undefined, // Honeypot + type: EventType.survey, + survey_token: (formData.get('survey-token') as string) || undefined, // Honeypot survey_vote: formData.get('survey-vote') === 'Y', - survey_comment: formData.get('survey-comment') || undefined, - survey_email: formData.get('survey-email') || undefined, + survey_comment: (formData.get('survey-comment') as string) || undefined, + survey_email: (formData.get('survey-email') as string) || undefined, }) } diff --git a/javascripts/airgap-links.js b/javascripts/airgap-links.js deleted file mode 100644 index 60dc82aa17..0000000000 --- a/javascripts/airgap-links.js +++ /dev/null @@ -1,17 +0,0 @@ -export default function airgapLinks () { - if (window.IS_NEXTJS_PAGE) return - - // When in an airgapped environment, - // show a tooltip on external links - const { airgap } = JSON.parse(document.getElementById('expose').text) - if (!airgap) return - - const externaLinks = Array.from( - document.querySelectorAll('a[href^="http"], a[href^="//"]') - ) - externaLinks.forEach(link => { - link.classList.add('tooltipped') - link.setAttribute('aria-label', 'This link may not work in this environment.') - link.setAttribute('rel', 'noopener') - }) -} diff --git a/javascripts/airgap-links.ts b/javascripts/airgap-links.ts new file mode 100644 index 0000000000..0453b5cfb5 --- /dev/null +++ b/javascripts/airgap-links.ts @@ -0,0 +1,17 @@ +export default function airgapLinks() { + // @ts-ignore + if (window.IS_NEXTJS_PAGE) return + + // When in an airgapped environment, + // show a tooltip on external links + const exposeEl = document?.getElementById('expose') as HTMLScriptElement + const { airgap } = JSON.parse(exposeEl.text) + if (!airgap) return + + const externaLinks = Array.from(document.querySelectorAll('a[href^="http"], a[href^="//"]')) + externaLinks.forEach((link) => { + link.classList.add('tooltipped') + link.setAttribute('aria-label', 'This link may not work in this environment.') + link.setAttribute('rel', 'noopener') + }) +} diff --git a/javascripts/all-articles.js b/javascripts/all-articles.js deleted file mode 100644 index 45fddf6605..0000000000 --- a/javascripts/all-articles.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Handles the client-side events for `includes/all-articles.html`. - */ -export default function allArticles () { - const buttons = document.querySelectorAll('button.js-all-articles-show-more') - - for (const btn of buttons) { - btn.addEventListener('click', evt => { - // Show all hidden links - const hiddenLinks = evt.currentTarget.parentElement.querySelectorAll('li.d-none') - for (const link of hiddenLinks) { - link.classList.remove('d-none') - } - // Remove the button, since we don't need it anymore - evt.currentTarget.parentElement.removeChild(evt.currentTarget) - }) - } -} diff --git a/javascripts/all-articles.ts b/javascripts/all-articles.ts new file mode 100644 index 0000000000..d10647a1c6 --- /dev/null +++ b/javascripts/all-articles.ts @@ -0,0 +1,17 @@ +/** + * Handles the client-side events for `includes/all-articles.html`. + */ +export default function allArticles() { + const buttons = document.querySelectorAll('button.js-all-articles-show-more') + + buttons.forEach((btn) => + btn.addEventListener('click', (evt) => { + const target = evt.currentTarget as HTMLButtonElement + // Show all hidden links + const hiddenLinks = target?.parentElement?.querySelectorAll('li.d-none') + hiddenLinks?.forEach((link) => link.classList.remove('d-none')) + // Remove the button, since we don't need it anymore + target?.parentElement?.removeChild(target) + }) + ) +} diff --git a/javascripts/browser-date-formatter.d.ts b/javascripts/browser-date-formatter.d.ts new file mode 100644 index 0000000000..35a06d98a5 --- /dev/null +++ b/javascripts/browser-date-formatter.d.ts @@ -0,0 +1,3 @@ +declare module 'browser-date-formatter' { + export default function browserDateFormatter(): void +} diff --git a/javascripts/copy-code.js b/javascripts/copy-code.ts similarity index 58% rename from javascripts/copy-code.js rename to javascripts/copy-code.ts index 28bf634403..709573bb11 100644 --- a/javascripts/copy-code.js +++ b/javascripts/copy-code.ts @@ -3,12 +3,13 @@ export default () => { if (!buttons) return - buttons.forEach(button => - button.addEventListener('click', async evt => { - const text = button.dataset.clipboardText + buttons.forEach((button) => + button.addEventListener('click', async () => { + const text = (button as HTMLElement).dataset.clipboardText + if (!text) return await navigator.clipboard.writeText(text) - const beforeTooltip = button.getAttribute('aria-label') + const beforeTooltip = button.getAttribute('aria-label') || '' button.setAttribute('aria-label', 'Copied!') setTimeout(() => { diff --git a/javascripts/dev-toc.js b/javascripts/dev-toc.ts similarity index 60% rename from javascripts/dev-toc.js rename to javascripts/dev-toc.ts index d62cac7c45..69c397c3b9 100644 --- a/javascripts/dev-toc.js +++ b/javascripts/dev-toc.ts @@ -1,7 +1,7 @@ const expandText = 'Expand All' const closeText = 'Close All' -export default function devToc () { +export default function devToc() { const expandButton = document.querySelector('.js-expand') if (!expandButton) return @@ -9,31 +9,29 @@ export default function devToc () { expandButton.addEventListener('click', () => { // on click, toggle all the details elements open or closed - const anyDetailsOpen = Array.from(detailsElements).find(details => details.open) + const anyDetailsOpen = Array.from(detailsElements).find((details) => details.open) - for (const detailsElement of detailsElements) { - anyDetailsOpen - ? detailsElement.removeAttribute('open') - : detailsElement.open = true - } + detailsElements.forEach((detailsElement) => { + anyDetailsOpen ? detailsElement.removeAttribute('open') : (detailsElement.open = true) + }) // toggle the button text on click anyDetailsOpen - ? expandButton.textContent = expandText - : expandButton.textContent = closeText + ? (expandButton.textContent = expandText) + : (expandButton.textContent = closeText) }) // also toggle the button text on clicking any of the details elements - for (const detailsElement of detailsElements) { + detailsElements.forEach((detailsElement) => { detailsElement.addEventListener('click', () => { expandButton.textContent = closeText // we can only get an accurate count of the open details elements if we wait a fraction after click setTimeout(() => { - if (!Array.from(detailsElements).find(details => details.open)) { + if (!Array.from(detailsElements).find((details) => details.open)) { expandButton.textContent = expandText } }, 50) }) - } + }) } diff --git a/javascripts/display-platform-specific-content.js b/javascripts/display-platform-specific-content.js deleted file mode 100644 index 76710e3466..0000000000 --- a/javascripts/display-platform-specific-content.js +++ /dev/null @@ -1,87 +0,0 @@ -import parseUserAgent from './user-agent' -const supportedPlatforms = ['mac', 'windows', 'linux'] -const detectedPlatforms = new Set() - -// Emphasize content for the visitor's OS (inferred from user agent string) - -export default function displayPlatformSpecificContent () { - let platform = getDefaultPlatform() || parseUserAgent().os - - // adjust platform names to fit existing mac/windows/linux scheme - if (!platform) platform = 'linux' - if (platform === 'ios') platform = 'mac' - - const platformsInContent = findPlatformSpecificContent(platform) - - hideSwitcherLinks(platformsInContent) - - showContentForPlatform(platform) - - // configure links for switching platform content - switcherLinks().forEach(link => { - link.addEventListener('click', (event) => { - event.preventDefault() - showContentForPlatform(event.target.dataset.platform) - findPlatformSpecificContent(event.target.dataset.platform) - }) - }) -} - -function showContentForPlatform (platform) { - // (de)activate switcher link appearances - switcherLinks().forEach(link => { - (link.dataset.platform === platform) - ? link.classList.add('selected') - : link.classList.remove('selected') - }) -} - -function findPlatformSpecificContent (platform) { - // find all platform-specific *block* elements and hide or show as appropriate - // example: {{ #mac }} block content {{/mac}} - Array.from(document.querySelectorAll('.extended-markdown')) - .filter(el => supportedPlatforms.some(platform => el.classList.contains(platform))) - .forEach(el => { - detectPlatforms(el) - el.style.display = el.classList.contains(platform) - ? '' - : 'none' - }) - - // find all platform-specific *inline* elements and hide or show as appropriate - // example: inline content - Array.from(document.querySelectorAll('.platform-mac, .platform-windows, .platform-linux')) - .forEach(el => { - detectPlatforms(el) - el.style.display = el.classList.contains('platform-' + platform) - ? '' - : 'none' - }) - - return Array.from(detectedPlatforms) -} - -// hide links for any platform-specific sections that are not present -function hideSwitcherLinks (platformsInContent) { - Array.from(document.querySelectorAll('a.platform-switcher')) - .forEach(link => { - if (platformsInContent.includes(link.dataset.platform)) return - link.style.display = 'none' - }) -} - -function detectPlatforms (el) { - el.classList.forEach(elClass => { - const value = elClass.replace(/platform-/, '') - if (supportedPlatforms.includes(value)) detectedPlatforms.add(value) - }) -} - -function getDefaultPlatform () { - const el = document.querySelector('[data-default-platform]') - if (el) return el.dataset.defaultPlatform -} - -function switcherLinks () { - return Array.from(document.querySelectorAll('a.platform-switcher')) -} diff --git a/javascripts/display-platform-specific-content.ts b/javascripts/display-platform-specific-content.ts new file mode 100644 index 0000000000..25c111a280 --- /dev/null +++ b/javascripts/display-platform-specific-content.ts @@ -0,0 +1,91 @@ +import parseUserAgent from './user-agent' +const supportedPlatforms = ['mac', 'windows', 'linux'] +const detectedPlatforms = new Set() + +// Emphasize content for the visitor's OS (inferred from user agent string) + +export default function displayPlatformSpecificContent() { + let platform = getDefaultPlatform() || parseUserAgent().os + + // adjust platform names to fit existing mac/windows/linux scheme + if (!platform) platform = 'linux' + if (platform === 'ios') platform = 'mac' + + const platformsInContent = findPlatformSpecificContent(platform) + + hideSwitcherLinks(platformsInContent) + + showContentForPlatform(platform) + + // configure links for switching platform content + switcherLinks().forEach((link) => { + link.addEventListener('click', (event) => { + event.preventDefault() + const target = event.target as HTMLElement + showContentForPlatform(target.dataset.platform || '') + findPlatformSpecificContent(target.dataset.platform || '') + }) + }) +} + +function showContentForPlatform(platform: string) { + // (de)activate switcher link appearances + switcherLinks().forEach((link) => { + link.dataset.platform === platform + ? link.classList.add('selected') + : link.classList.remove('selected') + }) +} + +function findPlatformSpecificContent(platform: string) { + // find all platform-specific *block* elements and hide or show as appropriate + // example: {{ #mac }} block content {{/mac}} + const markdowns = Array.from( + document.querySelectorAll('.extended-markdown') + ) as Array + markdowns + .filter((el) => supportedPlatforms.some((platform) => el.classList.contains(platform))) + .forEach((el) => { + detectPlatforms(el) + el.style.display = el.classList.contains(platform) ? '' : 'none' + }) + + // find all platform-specific *inline* elements and hide or show as appropriate + // example: inline content + const platforms = Array.from( + document.querySelectorAll('.platform-mac, .platform-windows, .platform-linux') + ) as Array + platforms.forEach((el) => { + detectPlatforms(el) + el.style.display = el.classList.contains('platform-' + platform) ? '' : 'none' + }) + + return Array.from(detectedPlatforms) as Array +} + +// hide links for any platform-specific sections that are not present +function hideSwitcherLinks(platformsInContent: Array) { + const links = Array.from( + document.querySelectorAll('a.platform-switcher') + ) as Array + links.forEach((link) => { + if (platformsInContent.includes(link.dataset.platform || '')) return + link.style.display = 'none' + }) +} + +function detectPlatforms(el: HTMLElement) { + el.classList.forEach((elClass) => { + const value = elClass.replace(/platform-/, '') + if (supportedPlatforms.includes(value)) detectedPlatforms.add(value) + }) +} + +function getDefaultPlatform() { + const el = document.querySelector('[data-default-platform]') as HTMLElement + if (el) return el.dataset.defaultPlatform +} + +function switcherLinks(): Array { + return Array.from(document.querySelectorAll('a.platform-switcher')) +} diff --git a/javascripts/display-tool-specific-content.js b/javascripts/display-tool-specific-content.js deleted file mode 100644 index ea3fb03cd5..0000000000 --- a/javascripts/display-tool-specific-content.js +++ /dev/null @@ -1,97 +0,0 @@ -import Cookies from 'js-cookie' -import { preserveAnchorNodePosition } from 'scroll-anchoring' - -import { sendEvent } from './events' - -const supportedTools = ['cli', 'desktop', 'webui', 'curl'] - -export default function displayToolSpecificContent () { - const toolElements = Array.from(document.querySelectorAll('.extended-markdown')) - .filter(el => supportedTools.some(tool => el.classList.contains(tool))) - - const detectedTools = toolElements - .flatMap(el => Array.from(el.classList).filter(className => supportedTools.includes(className))) - - const tool = getDefaultTool(detectedTools) - - showToolSpecificContent(tool, toolElements) - - hideSwitcherLinks(detectedTools) - - highlightTabForTool(tool) - - // configure links for switching tool content - switcherLinks().forEach(link => { - link.addEventListener('click', (event) => { - event.preventDefault() - highlightTabForTool(event.target.dataset.tool) - preserveAnchorNodePosition(document, () => { - showToolSpecificContent(event.target.dataset.tool, toolElements) - }) - - // Save this preference as a cookie. - Cookies.set('toolPreferred', event.target.dataset.tool, { sameSite: 'strict', secure: true }) - - // Send event data - sendEvent({ - type: 'preference', - preference_name: 'application', - preference_value: event.target.dataset.tool - }) - }) - }) -} - -function highlightTabForTool (tool) { - // (de)activate switcher link appearances - switcherLinks().forEach(link => { - (link.dataset.tool === tool) - ? link.classList.add('selected') - : link.classList.remove('selected') - }) -} - -function showToolSpecificContent (tool, toolElements) { - // show the content only for the highlighted tool - toolElements - .filter(el => supportedTools.some(tool => (el.classList.contains(tool)))) - .forEach(el => { - el.style.display = (el.classList.contains(tool)) - ? '' - : 'none' - }) -} - -// hide links for any tool-specific sections that are not present -function hideSwitcherLinks (detectedTools) { - Array.from(document.querySelectorAll('a.tool-switcher')) - .forEach(link => { - if (detectedTools.includes(link.dataset.tool)) return - link.style.display = 'none' - }) -} - -function getDefaultTool (detectedTools) { - // If the user selected a tool preference and the tool is present on this page - const cookieValue = Cookies.get('toolPreferred') - if (cookieValue && detectedTools.includes(cookieValue)) return cookieValue - - // If there is a default tool and the tool is present on this page - const defaultToolEl = document.querySelector('[data-default-tool]') - if (defaultToolEl && detectedTools.includes(defaultToolEl.dataset.defaultTool)) { - return defaultToolEl.dataset.defaultTool - } - - // Default to webui if present (this is generally the case where we show UI/CLI/Desktop info) - if (detectedTools.includes('webui')) return 'webui' - - // Default to cli if present (this is generally the case where we show curl/CLI info) - if (detectedTools.includes('cli')) return 'cli' - - // Otherwise, just choose the first detected tool - return detectedTools[0] -} - -function switcherLinks () { - return Array.from(document.querySelectorAll('a.tool-switcher')) -} diff --git a/javascripts/display-tool-specific-content.ts b/javascripts/display-tool-specific-content.ts new file mode 100644 index 0000000000..7f69193dd5 --- /dev/null +++ b/javascripts/display-tool-specific-content.ts @@ -0,0 +1,96 @@ +import Cookies from 'js-cookie' + +import { sendEvent, EventType } from './events' + +const supportedTools = ['cli', 'desktop', 'webui', 'curl'] + +export default function displayToolSpecificContent() { + const toolElements = Array.from(document.querySelectorAll('.extended-markdown')).filter((el) => + supportedTools.some((tool) => el.classList.contains(tool)) + ) as Array + + if (!toolElements.length) return + + const detectedTools = toolElements.flatMap((el) => + Array.from(el.classList).filter((className) => supportedTools.includes(className)) + ) as Array + + const tool = getDefaultTool(detectedTools) + + showToolSpecificContent(tool, toolElements) + + hideSwitcherLinks(detectedTools) + + highlightTabForTool(tool) + + // configure links for switching tool content + switcherLinks().forEach((link) => { + link.addEventListener('click', (event) => { + event.preventDefault() + const target = event.target as HTMLElement + highlightTabForTool(target.dataset.tool || '') + showToolSpecificContent(target.dataset.tool || '', toolElements) + + // Save this preference as a cookie. + Cookies.set('toolPreferred', target.dataset.tool || '', { sameSite: 'strict', secure: true }) + + // Send event data + sendEvent({ + type: EventType.preference, + preference_name: 'application', + preference_value: target.dataset.tool, + }) + }) + }) +} + +function highlightTabForTool(tool: string) { + // (de)activate switcher link appearances + switcherLinks().forEach((link) => { + link.dataset.tool === tool ? link.classList.add('selected') : link.classList.remove('selected') + }) +} + +function showToolSpecificContent(tool: string, toolElements: Array) { + // show the content only for the highlighted tool + toolElements + .filter((el) => supportedTools.some((tool) => el.classList.contains(tool))) + .forEach((el) => { + el.style.display = el.classList.contains(tool) ? '' : 'none' + }) +} + +// hide links for any tool-specific sections that are not present +function hideSwitcherLinks(detectedTools: Array) { + const links = Array.from(document.querySelectorAll('a.tool-switcher')) as Array + links.forEach((link) => { + if (detectedTools.includes(link.dataset.tool || '')) return + link.style.display = 'none' + }) +} + +function getDefaultTool(detectedTools: Array): string { + // If the user selected a tool preference and the tool is present on this page + const cookieValue = Cookies.get('toolPreferred') + if (cookieValue && detectedTools.includes(cookieValue)) return cookieValue + + // If there is a default tool and the tool is present on this page + const defaultToolEl = document.querySelector('[data-default-tool]') as HTMLElement + const defaultToolValue = defaultToolEl.dataset.defaultTool + if (defaultToolValue && detectedTools.includes(defaultToolValue)) { + return defaultToolValue + } + + // Default to webui if present (this is generally the case where we show UI/CLI/Desktop info) + if (detectedTools.includes('webui')) return 'webui' + + // Default to cli if present (this is generally the case where we show curl/CLI info) + if (detectedTools.includes('cli')) return 'cli' + + // Otherwise, just choose the first detected tool + return detectedTools[0] +} + +function switcherLinks() { + return Array.from(document.querySelectorAll('a.tool-switcher')) as Array +} diff --git a/javascripts/events.js b/javascripts/events.js deleted file mode 100644 index 3658ba6858..0000000000 --- a/javascripts/events.js +++ /dev/null @@ -1,246 +0,0 @@ -/* eslint-disable camelcase */ -import { v4 as uuidv4 } from 'uuid' -import Cookies from 'js-cookie' -import getCsrf from './get-csrf' -import parseUserAgent from './user-agent' - -const COOKIE_NAME = '_docs-events' - -const startVisitTime = Date.now() - -let cookieValue -let pageEventId -let maxScrollY = 0 -let pauseScrolling = false -let sentExit = false - -export function getUserEventsId () { - if (cookieValue) return cookieValue - cookieValue = Cookies.get(COOKIE_NAME) - if (cookieValue) return cookieValue - cookieValue = uuidv4() - Cookies.set(COOKIE_NAME, cookieValue, { - secure: true, - sameSite: 'strict', - expires: 365 - }) - return cookieValue -} - -export function sendEvent ({ - type, - version = '1.0.0', - // `= undefined` is a TypeScript hint. - exit_render_duration = undefined, - exit_first_paint = undefined, - exit_dom_interactive = undefined, - exit_dom_complete = undefined, - exit_visit_duration = undefined, - exit_scroll_length = undefined, - link_url = undefined, - search_query = undefined, - search_context = undefined, - navigate_label = undefined, - survey_token = undefined, // Honeypot, doesn't exist in schema - survey_vote = undefined, - survey_comment = undefined, - survey_email = undefined, - experiment_name = undefined, - experiment_variation = undefined, - experiment_success = undefined, - clipboard_operation = undefined, - preference_name = undefined, - preference_value = undefined -}) { - const body = { - _csrf: getCsrf(), - - type, // One of page, exit, link, search, navigate, survey, experiment, preference - - context: { - // Primitives - event_id: uuidv4(), - user: getUserEventsId(), - version, - created: new Date().toISOString(), - page_event_id: pageEventId, - - // Content information - path: location.pathname, - hostname: location.hostname, - referrer: document.referrer, - search: location.search, - href: location.href, - site_language: location.pathname.split('/')[1], - - // Device information - // os, os_version, browser, browser_version: - ...parseUserAgent(), - viewport_width: document.documentElement.clientWidth, - viewport_height: document.documentElement.clientHeight, - - // Location information - timezone: new Date().getTimezoneOffset() / -60, - user_language: navigator.language, - - // Preference information - application_preference: Cookies.get('toolPreferred') - }, - - // Page event - // No extra fields - - // Exit event - exit_render_duration, - exit_first_paint, - exit_dom_interactive, - exit_dom_complete, - exit_visit_duration, - exit_scroll_length, - - // Link event - link_url, - - // Search event - search_query, - search_context, - - // Navigate event - navigate_label, - - // Survey event - survey_token, // Honeypot, doesn't exist in schema - survey_vote, - survey_comment, - survey_email, - - // Experiment event - experiment_name, - experiment_variation, - experiment_success, - - // Clipboard event - clipboard_operation, - - // Preference event - preference_name, - preference_value - } - const blob = new Blob([JSON.stringify(body)], { type: 'application/json' }) - navigator.sendBeacon('/events', blob) - return body -} - -function getPerformance () { - const paint = performance?.getEntriesByType('paint')?.find( - ({ name }) => name === 'first-contentful-paint' - ) - const nav = performance?.getEntriesByType('navigation')?.[0] - return { - firstContentfulPaint: paint ? paint.startTime / 1000 : undefined, - domInteractive: nav ? nav.domInteractive / 1000 : undefined, - domComplete: nav ? nav.domComplete / 1000 : undefined, - render: nav ? (nav.responseEnd - nav.requestStart) / 1000 : undefined - } -} - -function trackScroll () { - // Throttle the calculations to no more than five per second - if (pauseScrolling) return - pauseScrolling = true - setTimeout(() => { pauseScrolling = false }, 200) - - // Update maximum scroll position reached - const scrollPosition = ( - (window.scrollY + window.innerHeight) / - document.documentElement.scrollHeight - ) - if (scrollPosition > maxScrollY) maxScrollY = scrollPosition -} - -function sendExit () { - if (sentExit) return - if (document.visibilityState !== 'hidden') return - sentExit = true - const { - render, - firstContentfulPaint, - domInteractive, - domComplete - } = getPerformance() - return sendEvent({ - type: 'exit', - exit_render_duration: render, - exit_first_paint: firstContentfulPaint, - exit_dom_interactive: domInteractive, - exit_dom_complete: domComplete, - exit_visit_duration: (Date.now() - startVisitTime) / 1000, - exit_scroll_length: maxScrollY - }) -} - -function initPageEvent () { - const pageEvent = sendEvent({ type: 'page' }) - pageEventId = pageEvent?.context?.event_id -} - -function initClipboardEvent () { - ['copy', 'cut', 'paste'].forEach(verb => { - document.documentElement.addEventListener(verb, () => { - sendEvent({ type: 'clipboard', clipboard_operation: verb }) - }) - }) -} - -function initLinkEvent () { - document.documentElement.addEventListener('click', evt => { - const link = evt.target.closest('a[href^="http"]') - if (!link) return - sendEvent({ - type: 'link', - link_url: link.href - }) - }) -} - -function initExitEvent () { - window.addEventListener('scroll', trackScroll) - document.addEventListener('visibilitychange', sendExit) -} - -function initNavigateEvent () { - if (!document.querySelector('.sidebar-products')) return - - Array.from( - document.querySelectorAll('.sidebar-products details') - ).forEach(details => details.addEventListener( - 'toggle', - evt => sendEvent({ - type: 'navigate', - navigate_label: `details ${evt.target.open ? 'open' : 'close'}: ${evt.target.querySelector('summary').innerText}` - }) - )) - - document.querySelector('.sidebar-products').addEventListener('click', evt => { - const link = evt.target.closest('a') - if (!link) return - sendEvent({ - type: 'navigate', - navigate_label: `link: ${link.href}` - }) - }) -} - -export default function initializeEvents () { - initPageEvent() // must come first - initExitEvent() - initLinkEvent() - initClipboardEvent() - initNavigateEvent() - // print event in ./print.js - // survey event in ./survey.js - // experiment event in ./experiment.js - // search event in ./search.js - // redirect event in middleware/record-redirect.js - // preference event in ./display-tool-specific-content.js -} diff --git a/javascripts/events.ts b/javascripts/events.ts new file mode 100644 index 0000000000..db0abac290 --- /dev/null +++ b/javascripts/events.ts @@ -0,0 +1,224 @@ +/* eslint-disable camelcase */ +import { v4 as uuidv4 } from 'uuid' +import Cookies from 'js-cookie' +import getCsrf from './get-csrf' +import parseUserAgent from './user-agent' + +const COOKIE_NAME = '_docs-events' + +const startVisitTime = Date.now() + +let cookieValue: string | undefined +let pageEventId: string | undefined +let maxScrollY = 0 +let pauseScrolling = false +let sentExit = false + +export function getUserEventsId() { + if (cookieValue) return cookieValue + cookieValue = Cookies.get(COOKIE_NAME) + if (cookieValue) return cookieValue + cookieValue = uuidv4() + Cookies.set(COOKIE_NAME, cookieValue, { + secure: true, + sameSite: 'strict', + expires: 365, + }) + return cookieValue +} + +export enum EventType { + page = 'page', + exit = 'exit', + link = 'link', + search = 'search', + navigate = 'navigate', + survey = 'survey', + experiment = 'experiment', + preference = 'preference', + clipboard = 'clipboard', + print = 'print', +} + +type SendEventProps = { + type: EventType + version?: string + exit_render_duration?: number + exit_first_paint?: number + exit_dom_interactive?: number + exit_dom_complete?: number + exit_visit_duration?: number + exit_scroll_length?: number + link_url?: string + search_query?: string + search_context?: string + navigate_label?: string + survey_token?: string // Honeypot, doesn't exist in schema + survey_vote?: boolean + survey_comment?: string + survey_email?: string + experiment_name?: string + experiment_variation?: string + experiment_success?: boolean + clipboard_operation?: string + preference_name?: string + preference_value?: string +} + +export function sendEvent({ type, version = '1.0.0', ...props }: SendEventProps) { + const body = { + _csrf: getCsrf(), + + type, + + context: { + // Primitives + event_id: uuidv4(), + user: getUserEventsId(), + version, + created: new Date().toISOString(), + page_event_id: pageEventId, + + // Content information + path: location.pathname, + hostname: location.hostname, + referrer: document.referrer, + search: location.search, + href: location.href, + site_language: location.pathname.split('/')[1], + + // Device information + // os, os_version, browser, browser_version: + ...parseUserAgent(), + viewport_width: document.documentElement.clientWidth, + viewport_height: document.documentElement.clientHeight, + + // Location information + timezone: new Date().getTimezoneOffset() / -60, + user_language: navigator.language, + + // Preference information + application_preference: Cookies.get('toolPreferred'), + }, + + ...props, + } + const blob = new Blob([JSON.stringify(body)], { type: 'application/json' }) + navigator.sendBeacon('/events', blob) + return body +} + +function getPerformance() { + const paint = performance + ?.getEntriesByType('paint') + ?.find(({ name }) => name === 'first-contentful-paint') + const nav = performance?.getEntriesByType('navigation')?.[0] as + | PerformanceNavigationTiming + | undefined + return { + firstContentfulPaint: paint ? paint.startTime / 1000 : undefined, + domInteractive: nav ? nav.domInteractive / 1000 : undefined, + domComplete: nav ? nav.domComplete / 1000 : undefined, + render: nav ? (nav.responseEnd - nav.requestStart) / 1000 : undefined, + } +} + +function trackScroll() { + // Throttle the calculations to no more than five per second + if (pauseScrolling) return + pauseScrolling = true + setTimeout(() => { + pauseScrolling = false + }, 200) + + // Update maximum scroll position reached + const scrollPixels = window.scrollY + window.innerHeight + const scrollPosition = scrollPixels / document.documentElement.scrollHeight + if (scrollPosition > maxScrollY) maxScrollY = scrollPosition +} + +function sendExit() { + if (sentExit) return + if (document.visibilityState !== 'hidden') return + sentExit = true + const { render, firstContentfulPaint, domInteractive, domComplete } = getPerformance() + return sendEvent({ + type: EventType.exit, + exit_render_duration: render, + exit_first_paint: firstContentfulPaint, + exit_dom_interactive: domInteractive, + exit_dom_complete: domComplete, + exit_visit_duration: (Date.now() - startVisitTime) / 1000, + exit_scroll_length: maxScrollY, + }) +} + +function initPageEvent() { + const pageEvent = sendEvent({ type: EventType.page }) + pageEventId = pageEvent?.context?.event_id +} + +function initClipboardEvent() { + ;['copy', 'cut', 'paste'].forEach((verb) => { + document.documentElement.addEventListener(verb, () => { + sendEvent({ type: EventType.clipboard, clipboard_operation: verb }) + }) + }) +} + +function initLinkEvent() { + document.documentElement.addEventListener('click', (evt) => { + const target = evt.target as HTMLElement + const link = target.closest('a[href^="http"]') as HTMLAnchorElement + if (!link) return + sendEvent({ + type: EventType.link, + link_url: link.href, + }) + }) +} + +function initExitEvent() { + window.addEventListener('scroll', trackScroll) + document.addEventListener('visibilitychange', sendExit) +} + +function initNavigateEvent() { + if (!document.querySelector('.sidebar-products')) return + + Array.from(document.querySelectorAll('.sidebar-products details')).forEach((details) => + details.addEventListener('toggle', (evt) => { + const target = evt.target as HTMLDetailsElement + sendEvent({ + type: EventType.navigate, + navigate_label: `details ${target.open ? 'open' : 'close'}: ${ + target?.querySelector('summary')?.innerText + }`, + }) + }) + ) + + document.querySelector('.sidebar-products')?.addEventListener('click', (evt) => { + const target = evt.target as HTMLElement + const link = target.closest('a') as HTMLAnchorElement + if (!link) return + sendEvent({ + type: EventType.navigate, + navigate_label: `link: ${link.href}`, + }) + }) +} + +export default function initializeEvents() { + initPageEvent() // must come first + initExitEvent() + initLinkEvent() + initClipboardEvent() + initNavigateEvent() + // print event in ./print.js + // survey event in ./survey.js + // experiment event in ./experiment.js + // search event in ./search.js + // redirect event in middleware/record-redirect.js + // preference event in ./display-tool-specific-content.js +} diff --git a/javascripts/experiment.js b/javascripts/experiment.ts similarity index 75% rename from javascripts/experiment.js rename to javascripts/experiment.ts index 9a090665bb..dd0228e76e 100644 --- a/javascripts/experiment.js +++ b/javascripts/experiment.ts @@ -1,22 +1,22 @@ import murmur from 'imurmurhash' -import { getUserEventsId, sendEvent } from './events' +import { getUserEventsId, sendEvent, EventType } from './events' // import h from './hyperscript' const TREATMENT = 'TREATMENT' const CONTROL = 'CONTROL' -export function bucket (test) { +export function bucket(test: string) { const id = getUserEventsId() const hash = murmur(test).hash(id).result() return hash % 2 ? TREATMENT : CONTROL } -export function sendSuccess (test) { +export function sendSuccess(test: string) { return sendEvent({ - type: 'experiment', + type: EventType.experiment, experiment_name: test, experiment_variation: bucket(test).toLowerCase(), - experiment_success: true + experiment_success: true, }) } diff --git a/javascripts/explorer.js b/javascripts/explorer.js deleted file mode 100644 index b91c506eea..0000000000 --- a/javascripts/explorer.js +++ /dev/null @@ -1,15 +0,0 @@ -const explorerUrl = location.hostname === 'localhost' - ? 'http://localhost:3000' - : 'https://graphql.github.com/explorer' - -// Pass non-search query params to Explorer app via the iFrame -export default function () { - const graphiqlExplorer = document.getElementById('graphiql') - const queryString = window.location.search - - if (!(queryString && graphiqlExplorer)) return - - window.onload = () => { - graphiqlExplorer.contentWindow.postMessage(queryString, explorerUrl) - } -} diff --git a/javascripts/explorer.ts b/javascripts/explorer.ts new file mode 100644 index 0000000000..44bfd0ee6d --- /dev/null +++ b/javascripts/explorer.ts @@ -0,0 +1,16 @@ +const explorerUrl = + location.hostname === 'localhost' + ? 'http://localhost:3000' + : 'https://graphql.github.com/explorer' + +// Pass non-search query params to Explorer app via the iFrame +export default function () { + const graphiqlExplorer = document.getElementById('graphiql') as HTMLIFrameElement + const queryString = window.location.search + + if (!(queryString && graphiqlExplorer)) return + + window.onload = () => { + graphiqlExplorer?.contentWindow?.postMessage(queryString, explorerUrl) + } +} diff --git a/javascripts/filter-cards.js b/javascripts/filter-cards.ts similarity index 67% rename from javascripts/filter-cards.js rename to javascripts/filter-cards.ts index e96b279002..d2c4a47065 100644 --- a/javascripts/filter-cards.js +++ b/javascripts/filter-cards.ts @@ -1,27 +1,29 @@ -function matchCardBySearch (card, searchString) { +function matchCardBySearch(card: HTMLElement, searchString: string) { const matchReg = new RegExp(searchString, 'i') // Check if this card matches - any `data-*` attribute contains the string - return Object.keys(card.dataset).some(key => matchReg.test(card.dataset[key])) + return Object.keys(card.dataset).some((key) => matchReg.test(card.dataset[key] || '')) } -function matchCardByAttribute (card, attribute, value) { +function matchCardByAttribute(card: HTMLElement, attribute: string, value: string): boolean { if (attribute in card.dataset) { - const allValues = card.dataset[attribute].split(',') - return allValues.some(key => key === value) + const allValues = (card.dataset[attribute] || '').split(',') + return allValues.some((key) => key === value) } return false } -export default function cardsFilter () { - const inputFilter = document.querySelector('.js-filter-card-filter') - const dropdownFilters = document.querySelectorAll('.js-filter-card-filter-dropdown') - const cards = Array.from(document.querySelectorAll('.js-filter-card')) - const showMoreButton = document.querySelector('.js-filter-card-show-more') - const noResults = document.querySelector('.js-filter-card-no-results') +export default function cardsFilter() { + const inputFilter = document.querySelector('.js-filter-card-filter') as HTMLInputElement + const dropdownFilters = Array.from( + document.querySelectorAll('.js-filter-card-filter-dropdown') + ) as Array + const cards = Array.from(document.querySelectorAll('.js-filter-card')) as Array + const showMoreButton = document.querySelector('.js-filter-card-show-more') as HTMLButtonElement + const noResults = document.querySelector('.js-filter-card-no-results') as HTMLElement // if jsFilterCardMax not set, assume no limit (well, at 99) // some landing pages don't include the button because the number of // guides is less than the max defined in includes/article-cards.html - const maxCards = showMoreButton ? parseInt(showMoreButton.dataset.jsFilterCardMax || 99) : 99 + const maxCards = parseInt(showMoreButton?.dataset?.jsFilterCardMax || '') || 99 const noFilter = () => { if (showMoreButton) showMoreButton.classList.remove('d-none') @@ -36,8 +38,8 @@ export default function cardsFilter () { } } - const filterEventHandler = (evt) => { - const { currentTarget } = evt + const filterEventHandler = (evt: Event) => { + const currentTarget = evt.currentTarget as HTMLSelectElement | HTMLInputElement const value = currentTarget.value if (showMoreButton) showMoreButton.classList.add('d-none') @@ -46,7 +48,7 @@ export default function cardsFilter () { let hasMatches = false for (let index = 0; index < cards.length; index++) { - const card = cards[index] + const card = cards[index] as HTMLElement let cardMatches = false @@ -62,7 +64,7 @@ export default function cardsFilter () { } if (currentTarget.tagName === 'SELECT' && currentTarget.name) { - const matches = [] + const matches: Array = [] // check all the other dropdowns dropdownFilters.forEach(({ name, value }) => { if (!name || !value) return @@ -75,7 +77,7 @@ export default function cardsFilter () { hasMatches = true continue } - cardMatches = matches.every(value => value) + cardMatches = matches.every((value) => value) } if (cardMatches) { @@ -88,9 +90,9 @@ export default function cardsFilter () { // If there wasn't at least one match, show the "no results" text if (!hasMatches) { - noResults.classList.remove('d-none') + noResults?.classList.remove('d-none') } else { - noResults.classList.add('d-none') + noResults?.classList.add('d-none') } return hasMatches @@ -100,19 +102,20 @@ export default function cardsFilter () { inputFilter.addEventListener('keyup', (evt) => { const hasMatches = filterEventHandler(evt) if (!hasMatches) { - document.querySelector('.js-filter-card-value').textContent = evt.currentTarget.value + const cardValueEl = document.querySelector('.js-filter-card-value') + if (cardValueEl) cardValueEl.textContent = (evt.currentTarget as HTMLInputElement)?.value } }) } if (dropdownFilters) { - dropdownFilters.forEach(filter => filter.addEventListener('change', filterEventHandler)) + dropdownFilters.forEach((filter) => filter.addEventListener('change', filterEventHandler)) } if (showMoreButton) { - showMoreButton.addEventListener('click', evt => { + showMoreButton.addEventListener('click', (evt: MouseEvent) => { // Number of cards that are currently visible - const numShown = cards.filter(card => !card.classList.contains('d-none')).length + const numShown = cards.filter((card) => !card.classList.contains('d-none')).length // We want to show n more cards const totalToShow = numShown + maxCards @@ -131,7 +134,7 @@ export default function cardsFilter () { // They're all shown now, we should hide the button if (totalToShow >= cards.length) { - evt.currentTarget.classList.add('d-none') + ;(evt?.currentTarget as HTMLElement)?.classList.add('d-none') } }) } diff --git a/javascripts/get-csrf.js b/javascripts/get-csrf.js deleted file mode 100644 index 825dce25cb..0000000000 --- a/javascripts/get-csrf.js +++ /dev/null @@ -1,6 +0,0 @@ -export default function getCsrf () { - const csrfEl = document - .querySelector('meta[name="csrf-token"]') - if (!csrfEl) return '' - return csrfEl.getAttribute('content') -} diff --git a/javascripts/get-csrf.ts b/javascripts/get-csrf.ts new file mode 100644 index 0000000000..d98f7da0f4 --- /dev/null +++ b/javascripts/get-csrf.ts @@ -0,0 +1,5 @@ +export default function getCsrf() { + const csrfEl = document.querySelector('meta[name="csrf-token"]') + if (!csrfEl) return '' + return csrfEl.getAttribute('content') +} diff --git a/javascripts/hyperscript.js b/javascripts/hyperscript.ts similarity index 59% rename from javascripts/hyperscript.js rename to javascripts/hyperscript.ts index dbe40350c9..ddef7d9c06 100644 --- a/javascripts/hyperscript.js +++ b/javascripts/hyperscript.ts @@ -2,24 +2,24 @@ const xmlns = 'http://www.w3.org/2000/svg' const plainObjectConstructor = {}.constructor -function exists (value) { +function exists(value: any) { return value !== null && typeof value !== 'undefined' } -function isPlainObject (value) { +function isPlainObject(value: any) { return value.constructor === plainObjectConstructor } -function isString (value) { +function isString(value: any) { return typeof value === 'string' } -function renderChildren (el, children) { +function renderChildren(el: HTMLElement | SVGElement, children: Array) { for (const child of children) { if (isPlainObject(child)) { Object.entries(child) - .filter(([key, value]) => exists(value)) - .forEach(([key, value]) => el.setAttribute(key, value)) + .filter(([, value]) => exists(value)) + .forEach(([key, value]) => el.setAttribute(key, value as string)) } else if (Array.isArray(child)) { renderChildren(el, child) } else if (isString(child)) { @@ -30,7 +30,7 @@ function renderChildren (el, children) { } } -export default function h (tagName, ...children) { +export default function h(tagName: string, ...children: Array) { const el = ['svg', 'path'].includes(tagName) ? document.createElementNS(xmlns, tagName) : document.createElement(tagName) @@ -39,6 +39,8 @@ export default function h (tagName, ...children) { } export const tags = Object.fromEntries( - ['div', 'form', 'a', 'input', 'button', 'ol', 'li', 'mark'] - .map(tagName => [tagName, (...args) => h(tagName, ...args)]) + ['div', 'form', 'a', 'input', 'button', 'ol', 'li', 'mark'].map((tagName) => [ + tagName, + (...args: Array) => h(tagName, ...args), + ]) ) diff --git a/javascripts/index.js b/javascripts/index.ts similarity index 100% rename from javascripts/index.js rename to javascripts/index.ts diff --git a/javascripts/localization.js b/javascripts/localization.ts similarity index 91% rename from javascripts/localization.js rename to javascripts/localization.ts index 87722a1896..c5283534bc 100644 --- a/javascripts/localization.js +++ b/javascripts/localization.ts @@ -1,5 +1,5 @@ export default function () { - const linkToEnglish = document.querySelector('#to-english-doc') + const linkToEnglish = document.querySelector('#to-english-doc') as HTMLAnchorElement if (linkToEnglish) { const pathname = window.location.pathname.split('/') diff --git a/javascripts/nav.js b/javascripts/nav.ts similarity index 100% rename from javascripts/nav.js rename to javascripts/nav.ts diff --git a/javascripts/print.js b/javascripts/print.ts similarity index 68% rename from javascripts/print.js rename to javascripts/print.ts index 0365fdae17..14c8a05494 100644 --- a/javascripts/print.js +++ b/javascripts/print.ts @@ -1,9 +1,9 @@ -import { sendEvent } from './events' +import { EventType, sendEvent } from './events' export default function () { const printButtons = document.querySelectorAll('.js-print') - Array.from(printButtons).forEach(btn => { + Array.from(printButtons).forEach((btn) => { // Open the print dialog when the button is clicked btn.addEventListener('click', () => { window.print() @@ -12,6 +12,6 @@ export default function () { // Track print events window.onbeforeprint = function () { - sendEvent({ type: 'print' }) + sendEvent({ type: EventType.print }) } } diff --git a/javascripts/release-notes.js b/javascripts/release-notes.js deleted file mode 100644 index 176b0321d1..0000000000 --- a/javascripts/release-notes.js +++ /dev/null @@ -1,19 +0,0 @@ -export default function releaseNotes () { - if (window.next) return - const patches = Array.from(document.querySelectorAll('.js-release-notes-patch')) - if (patches.length === 0) return - - const observer = new IntersectionObserver((entries) => { - for (const entry of entries) { - const { version } = entry.target.dataset - const patchLink = document.querySelector(`.js-release-notes-patch-link[data-version="${version}"]`) - patchLink.classList.toggle('selected', entry.isIntersecting) - } - }, { - rootMargin: '-40% 0px -50%' - }) - - patches.forEach(patch => { - observer.observe(patch) - }) -} diff --git a/javascripts/release-notes.ts b/javascripts/release-notes.ts new file mode 100644 index 0000000000..3ca4767c16 --- /dev/null +++ b/javascripts/release-notes.ts @@ -0,0 +1,26 @@ +export default function releaseNotes() { + // @ts-ignore + if (window.IS_NEXTJS_PAGE) return + + const patches = Array.from(document.querySelectorAll('.js-release-notes-patch')) + if (patches.length === 0) return + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const { version } = (entry.target as HTMLElement).dataset + const patchLink = document.querySelector( + `.js-release-notes-patch-link[data-version="${version}"]` + ) + patchLink?.classList.toggle('selected', entry.isIntersecting) + } + }, + { + rootMargin: '-40% 0px -50%', + } + ) + + patches.forEach((patch) => { + observer.observe(patch) + }) +} diff --git a/javascripts/scroll-up.js b/javascripts/scroll-up.ts similarity index 87% rename from javascripts/scroll-up.js rename to javascripts/scroll-up.ts index 3a28db997c..e4604e02f8 100644 --- a/javascripts/scroll-up.js +++ b/javascripts/scroll-up.ts @@ -3,10 +3,10 @@ export default function () { const PageTopBtn = document.getElementById('js-scroll-top') if (!PageTopBtn) return - PageTopBtn.addEventListener('click', (e) => { + PageTopBtn.addEventListener('click', () => { window.scrollTo({ top: 0, - behavior: 'smooth' + behavior: 'smooth', }) }) diff --git a/javascripts/search-with-your-keyboard.d.ts b/javascripts/search-with-your-keyboard.d.ts new file mode 100644 index 0000000000..b33fead8f5 --- /dev/null +++ b/javascripts/search-with-your-keyboard.d.ts @@ -0,0 +1,3 @@ +declare module 'search-with-your-keyboard' { + export default function searchWithYourKeyboard(inputSelector: string, hitsSelector: string): void +} diff --git a/javascripts/search.js b/javascripts/search.ts similarity index 59% rename from javascripts/search.js rename to javascripts/search.ts index 8ef6d05283..5d1a84e45c 100644 --- a/javascripts/search.js +++ b/javascripts/search.ts @@ -1,20 +1,21 @@ import { tags } from './hyperscript' -import { sendEvent } from './events' +import { sendEvent, EventType } from './events' import searchWithYourKeyboard from 'search-with-your-keyboard' -let $searchInputContainer -let $searchResultsContainer -let $searchOverlay -let $searchInput +let $searchInputContainer: HTMLElement | null | undefined +let $searchResultsContainer: HTMLElement | null | undefined +let $searchOverlay: HTMLElement | null | undefined +let $searchInput: HTMLInputElement | null | undefined -let isExplorerPage +let isExplorerPage: boolean // This is our default placeholder, but it can be localized with a tag let placeholder = 'Search topics, products...' -let version -let language +let version: string +let language: string -export default function search () { +export default function search() { + // @ts-ignore if (window.IS_NEXTJS_PAGE) return // We don't want to mess with query params intended for the GraphQL Explorer @@ -26,30 +27,32 @@ export default function search () { if (!$searchInputContainer || !$searchResultsContainer) return // This overlay exists so if you click off the search, it closes - $searchOverlay = document.querySelector('.search-overlay-desktop') + $searchOverlay = document.querySelector('.search-overlay-desktop') as HTMLElement // There's an index for every version/language combination - const { - languages, - versions, - nonEnterpriseDefaultVersion - } = JSON.parse(document.getElementById('expose').text).searchOptions + const { languages, versions, nonEnterpriseDefaultVersion } = JSON.parse( + (document.getElementById('expose') as HTMLScriptElement)?.text || '' + ).searchOptions version = deriveVersionFromPath(versions, nonEnterpriseDefaultVersion) language = deriveLanguageCodeFromPath(languages) // Find search placeholder text in a tag, falling back to a default - const $placeholderMeta = document.querySelector('meta[name="site.data.ui.search.placeholder"]') + const $placeholderMeta = document.querySelector( + 'meta[name="site.data.ui.search.placeholder"]' + ) as HTMLMetaElement if ($placeholderMeta) { placeholder = $placeholderMeta.content } // Write the search form into its container $searchInputContainer.append(tmplSearchInput()) - $searchInput = $searchInputContainer.querySelector('input') + $searchInput = $searchInputContainer.querySelector('input') as HTMLInputElement // Prevent 'enter' from refreshing the page - $searchInputContainer.querySelector('form') - .addEventListener('submit', evt => evt.preventDefault()) + ;($searchInputContainer.querySelector('form') as HTMLFormElement).addEventListener( + 'submit', + (evt) => evt.preventDefault() + ) // Search when the user finished typing $searchInput.addEventListener('keyup', debounce(onSearch)) @@ -67,12 +70,11 @@ export default function search () { } // The home page and 404 pages have a standalone search -function hasStandaloneSearch () { - return document.getElementById('landing') || - document.querySelector('body.error-404') !== null +function hasStandaloneSearch() { + return document.getElementById('landing') || document.querySelector('body.error-404') !== null } -function toggleSearchDisplay () { +function toggleSearchDisplay() { // Clear/close search, if ESC is clicked document.addEventListener('keyup', (e) => { if (e.key === 'Escape') { @@ -84,7 +86,7 @@ function toggleSearchDisplay () { if (hasStandaloneSearch()) return // Open panel if input is clicked - $searchInput.addEventListener('focus', openSearch) + $searchInput?.addEventListener('focus', openSearch) // Close panel if overlay is clicked if ($searchOverlay) { @@ -92,60 +94,63 @@ function toggleSearchDisplay () { } // Open panel if page loads with query in the params/input - if ($searchInput.value) { + if ($searchInput?.value) { openSearch() } } // On most pages, opens the search panel -function openSearch () { - $searchInput.classList.add('js-open') - $searchResultsContainer.classList.add('js-open') - $searchOverlay.classList.add('js-open') +function openSearch() { + $searchInput?.classList.add('js-open') + $searchResultsContainer?.classList.add('js-open') + $searchOverlay?.classList.add('js-open') } // Close panel if not on homepage -function closeSearch () { +function closeSearch() { if (!hasStandaloneSearch()) { - $searchInput.classList.remove('js-open') - $searchResultsContainer.classList.remove('js-open') - $searchOverlay.classList.remove('js-open') + $searchInput?.classList.remove('js-open') + $searchResultsContainer?.classList.remove('js-open') + $searchOverlay?.classList.remove('js-open') } - $searchInput.value = '' + if ($searchInput) $searchInput.value = '' onSearch() } -function deriveLanguageCodeFromPath (languageCodes) { +function deriveLanguageCodeFromPath(languageCodes: Array) { let languageCode = location.pathname.split('/')[1] if (!languageCodes.includes(languageCode)) languageCode = 'en' return languageCode } -function deriveVersionFromPath (allVersions, nonEnterpriseDefaultVersion) { +function deriveVersionFromPath( + allVersions: Record, + nonEnterpriseDefaultVersion: string +) { // fall back to the non-enterprise default version (FPT currently) on the homepage, 404 page, etc. const versionStr = location.pathname.split('/')[2] || nonEnterpriseDefaultVersion return allVersions[versionStr] || allVersions[nonEnterpriseDefaultVersion] } // Wait for the event to stop triggering for X milliseconds before responding -function debounce (fn, delay = 300) { - let timer - return (...args) => { +function debounce(fn: Function, delay = 300) { + let timer: number + return (...args: Array) => { clearTimeout(timer) - timer = setTimeout(() => fn.apply(null, args), delay) + timer = window.setTimeout(() => fn.apply(null, args), delay) } } // When the user finishes typing, update the results -async function onSearch () { - const query = $searchInput.value +async function onSearch() { + const query = $searchInput?.value || '' // Update the URL with the search parameters in the query string // UNLESS this is the GraphQL Explorer page, where a query in the URL is a GraphQL query - const pushUrl = new URL(location) - pushUrl.search = query && !isExplorerPage ? new URLSearchParams({ query }) : '' - history.pushState({}, '', pushUrl) + const pushUrl = new URL(location.toString()) + pushUrl.search = query && !isExplorerPage ? new URLSearchParams({ query }).toString() : '' + history.pushState({}, '', pushUrl.toString()) // If there's a query, call the endpoint // Otherwise, there's no results by default @@ -153,74 +158,78 @@ async function onSearch () { if (query.trim()) { const endpointUrl = new URL(location.origin) endpointUrl.pathname = '/search' - endpointUrl.search = new URLSearchParams({ language, version, query }) + endpointUrl.search = new URLSearchParams({ language, version, query }).toString() - const response = await fetch(endpointUrl, { + const response = await fetch(endpointUrl.toString(), { method: 'GET', headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, }) results = response.ok ? await response.json() : [] } // Either way, update the display - $searchResultsContainer.querySelectorAll('*').forEach(el => el.remove()) - $searchResultsContainer.append( - tmplSearchResults(results) - ) + $searchResultsContainer?.querySelectorAll('*').forEach((el) => el.remove()) + $searchResultsContainer?.append(tmplSearchResults(results)) toggleStandaloneSearch() // Analytics tracking if (query.trim()) { sendEvent({ - type: 'search', - search_query: query + type: EventType.search, + search_query: query, // search_context }) } } // If on homepage, toggle results container if query is present -function toggleStandaloneSearch () { +function toggleStandaloneSearch() { if (!hasStandaloneSearch()) return - const query = $searchInput.value - const queryPresent = query && query.length > 0 - const $results = document.querySelector('.ais-Hits') + const query = $searchInput?.value + const queryPresent = Boolean(query && query.length > 0) + const $results = document.querySelector('.ais-Hits') as HTMLElement // Primer classNames for showing and hiding the results container - const activeClass = $searchResultsContainer.getAttribute('data-active-class') - const inactiveClass = $searchResultsContainer.getAttribute('data-inactive-class') + const activeClass = $searchResultsContainer?.getAttribute('data-active-class') + const inactiveClass = $searchResultsContainer?.getAttribute('data-inactive-class') if (!activeClass) { - console.error('container is missing required `data-active-class` attribute', $searchResultsContainer) + console.error( + 'container is missing required `data-active-class` attribute', + $searchResultsContainer + ) return } if (!inactiveClass) { - console.error('container is missing required `data-inactive-class` attribute', $searchResultsContainer) + console.error( + 'container is missing required `data-inactive-class` attribute', + $searchResultsContainer + ) return } // hide the container when no query is present - $searchResultsContainer.classList.toggle(activeClass, queryPresent) - $searchResultsContainer.classList.toggle(inactiveClass, !queryPresent) + $searchResultsContainer?.classList.toggle(activeClass, queryPresent) + $searchResultsContainer?.classList.toggle(inactiveClass, !queryPresent) if (queryPresent && $results) $results.style.display = 'block' } // If the user shows up with a query in the URL, go ahead and search for it -function parseExistingSearch () { +function parseExistingSearch() { const params = new URLSearchParams(location.search) if (!params.has('query')) return - $searchInput.value = params.get('query') + if ($searchInput) $searchInput.value = params.get('query') || '' onSearch() } /** * Template functions ***/ -function tmplSearchInput () { +function tmplSearchInput() { // only autofocus on the homepage, and only if no #hash is present in the URL const autofocus = (hasStandaloneSearch() && !location.hash.length) || null const { div, form, input, button } = tags @@ -237,40 +246,47 @@ function tmplSearchInput () { autocorrect: 'off', autocapitalize: 'off', spellcheck: 'false', - maxlength: '512' + maxlength: '512', }), button({ class: 'ais-SearchBox-submit', type: 'submit', title: 'Submit the search query.', - hidden: true + hidden: true, }) ) ) } -function tmplSearchResults (items) { +type SearchResult = { + url: string + breadcrumbs: string + heading: string + title: string + content: string +} + +function tmplSearchResults(items: Array) { const { div, ol, li } = tags return div( { class: 'ais-Hits', style: 'display:block' }, ol( { class: 'ais-Hits-list' }, - items.map(item => li( - { class: 'ais-Hits-item' }, - tmplSearchResult(item) - )) + items.map((item) => li({ class: 'ais-Hits-item' }, tmplSearchResult(item))) ) ) } -function tmplSearchResult ({ url, breadcrumbs, heading, title, content }) { +function tmplSearchResult({ url, breadcrumbs, heading, title, content }: SearchResult) { const { div, a } = tags return div( { class: 'search-result border-top color-border-secondary py-3 px-2' }, a( { href: url, class: 'no-underline' }, div( - { class: 'search-result-breadcrumbs d-block color-text-primary opacity-60 text-small pb-1' }, + { + class: 'search-result-breadcrumbs d-block color-text-primary opacity-60 text-small pb-1', + }, // Breadcrumbs in search records don't include the page title markify(breadcrumbs || '') ), @@ -279,18 +295,13 @@ function tmplSearchResult ({ url, breadcrumbs, heading, title, content }) { // Display page title and heading (if present exists) markify(heading ? `${title}: ${heading}` : title) ), - div( - { class: 'search-result-content d-block color-text-secondary' }, - markify(content) - ) + div({ class: 'search-result-content d-block color-text-secondary' }, markify(content)) ) ) } // Convert mark tags in search responses -function markify (text) { +function markify(text: string) { const { mark } = tags - return text - .split(/<\/?mark>/g) - .map((el, i) => i % 2 ? mark(el) : el) + return text.split(/<\/?mark>/g).map((el, i) => (i % 2 ? mark(el) : el)) } diff --git a/javascripts/set-next-env.js b/javascripts/set-next-env.ts similarity index 53% rename from javascripts/set-next-env.js rename to javascripts/set-next-env.ts index bfd25bd542..637d594b66 100644 --- a/javascripts/set-next-env.js +++ b/javascripts/set-next-env.ts @@ -1,3 +1,4 @@ -export default function setNextEnv () { +export default function setNextEnv() { + // @ts-ignore window.IS_NEXTJS_PAGE = !!document.querySelector('#__next') } diff --git a/javascripts/show-more.js b/javascripts/show-more.ts similarity index 70% rename from javascripts/show-more.js rename to javascripts/show-more.ts index d67ef4d362..3bb4520b9d 100644 --- a/javascripts/show-more.js +++ b/javascripts/show-more.ts @@ -10,20 +10,21 @@ *
hidden item
* * -*/ + */ -export default function showMore () { +export default function showMore() { const buttons = document.querySelectorAll('.js-show-more-button') - for (const btn of buttons) { - btn.addEventListener('click', evt => { - const container = evt.currentTarget.closest('.js-show-more-container') + buttons.forEach((btn) => { + btn.addEventListener('click', (evt) => { + const target = evt.currentTarget as HTMLButtonElement + const container = target.closest('.js-show-more-container') if (!container) return const hiddenLinks = container.querySelectorAll('.js-show-more-item.d-none') // get number of items to show more of, if not set, show all remaining items - const showMoreNum = evt.currentTarget.dataset.jsShowMoreItems || hiddenLinks.length + const showMoreNum = target.dataset.jsShowMoreItems || hiddenLinks.length let count = 0 - for (const link of hiddenLinks) { + for (const link of Array.from(hiddenLinks)) { if (count++ >= showMoreNum) { break } @@ -31,8 +32,8 @@ export default function showMore () { } // Remove the button if all items have been shown if (container.querySelectorAll('.js-show-more-item.d-none').length === 0) { - evt.currentTarget.parentElement.removeChild(evt.currentTarget) + target?.parentElement?.removeChild(target) } }) - } + }) } diff --git a/javascripts/sidebar.js b/javascripts/sidebar.ts similarity index 70% rename from javascripts/sidebar.js rename to javascripts/sidebar.ts index baae735d44..4eb12033a8 100644 --- a/javascripts/sidebar.js +++ b/javascripts/sidebar.ts @@ -1,28 +1,30 @@ export default function () { // TODO override active classes set on server side if sidebar elements are clicked - const activeMenuItem = document.querySelector('.sidebar .active') + const activeMenuItem = document.querySelector('.sidebar .active') as HTMLElement if (!activeMenuItem) return const verticalBufferAboveActiveItem = 40 const activeMenuItemPosition = activeMenuItem.offsetTop - verticalBufferAboveActiveItem const menu = document.querySelector('.sidebar') - if (activeMenuItemPosition > (window.innerHeight * 0.5)) { - menu.scrollTo(0, activeMenuItemPosition) + if (activeMenuItemPosition > window.innerHeight * 0.5) { + menu?.scrollTo(0, activeMenuItemPosition) } // if the active category is a standalone category, do not close the other open dropdowns - const activeStandaloneCategory = document.querySelectorAll('.sidebar-category.active.standalone-category') + const activeStandaloneCategory = document.querySelectorAll( + '.sidebar-category.active.standalone-category' + ) if (activeStandaloneCategory.length) return const allOpenDetails = document.querySelectorAll('.sidebar-category:not(.active) details[open]') if (allOpenDetails) { - for (const openDetail of allOpenDetails) { + for (const openDetail of Array.from(allOpenDetails)) { openDetail.removeAttribute('open') const svgArrowElem = openDetail.querySelector('summary > div > svg') - svgArrowElem.remove() + svgArrowElem?.remove() } } } diff --git a/javascripts/survey.js b/javascripts/survey.js deleted file mode 100644 index a20524de95..0000000000 --- a/javascripts/survey.js +++ /dev/null @@ -1,77 +0,0 @@ -import { sendEvent } from './events' - -function showElement (el) { - el.removeAttribute('hidden') -} - -function hideElement (el) { - el.setAttribute('hidden', true) -} - -function updateDisplay (form, state) { - Array.from( - form.querySelectorAll( - ['start', 'yes', 'no', 'end'] - .map(xstate => '[data-help-' + xstate + ']') - .join(',') - ) - ) - .forEach(hideElement) - Array.from(form.querySelectorAll('[data-help-' + state + ']')) - .forEach(showElement) -} - -function submitForm (form) { - const formData = new FormData(form) - const data = Object.fromEntries( - Array.from(formData.entries()) - .map( - ([key, value]) => [ - key.replace('survey-', ''), - value || undefined // Convert empty strings to undefined - ] - ) - ) - return trackEvent(data) -} - -function trackEvent ({ token, vote, email, comment }) { - return sendEvent({ - type: 'survey', - token, // Honeypot - survey_vote: vote === 'Yes', - survey_comment: comment, - survey_email: email - }) -} - -export default function survey () { - if (window.IS_NEXTJS_PAGE) return - - const form = document.querySelector('.js-survey') - const texts = Array.from(document.querySelectorAll('.js-survey input, .js-survey textarea')) - const votes = Array.from(document.querySelectorAll('.js-survey [type=radio]')) - if (!form || !texts.length || !votes.length) return - - form.addEventListener('submit', evt => { - evt.preventDefault() - submitForm(form) - updateDisplay(form, 'end') - }) - - votes.forEach(voteEl => { - voteEl.addEventListener('change', evt => { - const state = evt.target.value.toLowerCase() - const form = voteEl.closest('form') - submitForm(form) - updateDisplay(form, state) - }) - }) - - // Prevent the site search from overtaking your input - texts.forEach(text => { - text.addEventListener('keydown', evt => { - if (evt.code === 'Slash') evt.stopPropagation() - }) - }) -} diff --git a/javascripts/survey.ts b/javascripts/survey.ts new file mode 100644 index 0000000000..a46381da98 --- /dev/null +++ b/javascripts/survey.ts @@ -0,0 +1,69 @@ +import { sendEvent, EventType } from './events' + +function showElement(el: HTMLElement) { + el.removeAttribute('hidden') +} + +function hideElement(el: HTMLElement) { + el.setAttribute('hidden', 'hidden') +} + +function updateDisplay(form: HTMLFormElement, state: string) { + const allSelector = ['start', 'yes', 'no', 'end'] + .map((xstate) => '[data-help-' + xstate + ']') + .join(',') + const stateSelector = '[data-help-' + state + ']' + const allEls = Array.from(form.querySelectorAll(allSelector)) as Array + allEls.forEach(hideElement) + const stateEls = Array.from(form.querySelectorAll(stateSelector)) as Array + stateEls.forEach(showElement) +} + +function submitForm(form: HTMLFormElement) { + const formData = new FormData(form) + return trackEvent(formData) +} + +function trackEvent(formData: FormData) { + return sendEvent({ + type: EventType.survey, + survey_token: (formData.get('survey-token') as string) || undefined, // Honeypot + survey_vote: formData.get('survey-vote') === 'Yes', + survey_comment: (formData.get('survey-comment') as string) || undefined, + survey_email: (formData.get('survey-email') as string) || undefined, + }) +} + +export default function survey() { + // @ts-ignore + if (window.IS_NEXTJS_PAGE) return + + const form = document.querySelector('.js-survey') as HTMLFormElement | null + const texts = Array.from( + document.querySelectorAll('.js-survey input, .js-survey textarea') + ) as Array + const votes = Array.from(document.querySelectorAll('.js-survey [type=radio]')) + if (!form || !texts.length || !votes.length) return + + form.addEventListener('submit', (evt) => { + evt.preventDefault() + submitForm(form) + updateDisplay(form, 'end') + }) + + votes.forEach((voteEl) => { + voteEl.addEventListener('change', (evt) => { + const radio = evt.target as HTMLInputElement + const state = radio.value.toLowerCase() + submitForm(form) + updateDisplay(form, state) + }) + }) + + // Prevent the site search from overtaking your input + texts.forEach((text) => { + text.addEventListener('keydown', (evt: KeyboardEvent) => { + if (evt.code === 'Slash') evt.stopPropagation() + }) + }) +} diff --git a/javascripts/toggle-images.js b/javascripts/toggle-images.ts similarity index 73% rename from javascripts/toggle-images.js rename to javascripts/toggle-images.ts index cb309b1f35..54d91abb2e 100644 --- a/javascripts/toggle-images.js +++ b/javascripts/toggle-images.ts @@ -17,7 +17,7 @@ export default function () { // If there are no images on the page, return! // Don't include images in tables, which are already small and shouldn't be hidden. - const images = [...document.querySelectorAll('img')].filter(img => !img.closest('table')) + const images = Array.from(document.querySelectorAll('img')).filter((img) => !img.closest('table')) if (!images.length) return // The button is hidden by default so it doesn't appear on browsers with JS disabled. @@ -25,7 +25,7 @@ export default function () { toggleImagesBtn.removeAttribute('hidden') // Look for a cookie with image visibility preference; otherwise, use the default. - const hideImagesPreferred = (Cookies.get('hideImagesPreferred') === 'true') || hideImagesByDefault + const hideImagesPreferred = Cookies.get('hideImagesPreferred') === 'true' || hideImagesByDefault // Hide the images if that is the preference. if (hideImagesPreferred) { @@ -37,15 +37,15 @@ export default function () { const onIcon = document.getElementById('js-on-icon') // Get the aria-labels from the span elements for the tooltips. - const tooltipImagesOff = offIcon.getAttribute('aria-label') - const tooltipImagesOn = onIcon.getAttribute('aria-label') + const tooltipImagesOff = offIcon?.getAttribute('aria-label') || '' + const tooltipImagesOn = onIcon?.getAttribute('aria-label') || '' // Set the starting state depending on user preferences. if (hideImagesPreferred) { - offIcon.removeAttribute('hidden') + offIcon?.removeAttribute('hidden') toggleImagesBtn.setAttribute('aria-label', tooltipImagesOff) } else { - onIcon.removeAttribute('hidden') + onIcon?.removeAttribute('hidden') toggleImagesBtn.setAttribute('aria-label', tooltipImagesOn) } @@ -53,27 +53,32 @@ export default function () { // If images are not hidden by default, showOnNextClick should be true. let showOnNextClick = !hideImagesPreferred - toggleImagesBtn.addEventListener('click', (e) => { + toggleImagesBtn.addEventListener('click', () => { if (showOnNextClick) { // Button should say "Images are off" on first click (depending on prefs) - offIcon.removeAttribute('hidden') - onIcon.setAttribute('hidden', true) + offIcon?.removeAttribute('hidden') + onIcon?.setAttribute('hidden', 'hidden') toggleImagesBtn.setAttribute('aria-label', tooltipImagesOff) toggleImages(images, 'hide') } else { // Button should say "Images are on" on another click - offIcon.setAttribute('hidden', true) - onIcon.removeAttribute('hidden') + offIcon?.setAttribute('hidden', 'hidden') + onIcon?.removeAttribute('hidden') toggleImagesBtn.setAttribute('aria-label', tooltipImagesOn) toggleImages(images, 'show') } // Remove focus from the button after click so the tooltip does not stay displayed. // Use settimeout to work around Firefox-specific issue. - setTimeout(() => { toggleImagesBtn.blur() }, 100) + setTimeout(() => { + toggleImagesBtn.blur() + }, 100) // Save this preference as a cookie. - Cookies.set('hideImagesPreferred', showOnNextClick, { sameSite: 'strict', secure: true }) + Cookies.set('hideImagesPreferred', showOnNextClick.toString(), { + sameSite: 'strict', + secure: true, + }) // Toggle the action on every click. showOnNextClick = !showOnNextClick @@ -83,28 +88,28 @@ export default function () { }) } -function toggleImages (images, action) { +function toggleImages(images: Array, action: string) { for (const img of images) { toggleImage(img, action) } } -function toggleImage (img, action) { - const parentEl = img.parentNode +function toggleImage(img: HTMLImageElement, action: string) { + const parentEl = img.parentNode as HTMLElement // Style the parent element and image depending on the state. if (action === 'show') { - img.src = img.getAttribute('originalSrc') + img.src = img.getAttribute('originalSrc') || '' img.style.border = '2px solid var(--color-auto-gray-2)' parentEl.style.display = 'block' - parentEl.style['margin-top'] = '20px' + parentEl.style.marginTop = '20px' parentEl.style.padding = '10px 0' } else { if (!img.getAttribute('originalSrc')) img.setAttribute('originalSrc', img.src) img.src = placeholderImagePath img.style.border = 'none' parentEl.style.display = 'inline' - parentEl.style['margin-top'] = '0' + parentEl.style.marginTop = '0' parentEl.style.padding = '1px 6px' } } diff --git a/javascripts/user-agent.js b/javascripts/user-agent.ts similarity index 58% rename from javascripts/user-agent.js rename to javascripts/user-agent.ts index 2f76724992..60d52a14b7 100644 --- a/javascripts/user-agent.js +++ b/javascripts/user-agent.ts @@ -7,7 +7,7 @@ const OS_REGEXPS = [ /(windows) ([^);]+)/i, /(android) ([^);]+)/i, /(cros) ([^);]+)/i, - /(linux) ([^);]+)/i + /(linux) ([^);]+)/i, ] // The order matters with these @@ -16,17 +16,15 @@ const BROWSER_REGEXPS = [ /(edge)\/([^\s)]+)/i, /(chrome)\/([^\s)]+)/i, /(safari)\/([^\s)]+)/i, - /ms(ie)\/([^\s)]+)/i + /ms(ie)\/([^\s)]+)/i, ] -export default function parseUserAgent (ua = navigator.userAgent) { +export default function parseUserAgent(ua = navigator.userAgent) { ua = ua.toLowerCase() - let [, os = 'other', os_version = '0'] = ua.match( - OS_REGEXPS.find(re => re.test(ua)) - ) + const osRe = OS_REGEXPS.find((re) => re.test(ua)) + let [, os = 'other', os_version = '0'] = (osRe && ua.match(osRe)) || [] if (os === 'iphone os' || os === 'ipad os') os = 'ios' - const [, browser = 'other', browser_version = '0'] = ua.match( - BROWSER_REGEXPS.find(re => re.test(ua)) - ) + const browserRe = BROWSER_REGEXPS.find((re) => re.test(ua)) + const [, browser = 'other', browser_version = '0'] = (browserRe && ua.match(browserRe)) || [] return { os, os_version, browser, browser_version } } diff --git a/javascripts/wrap-code-terms.js b/javascripts/wrap-code-terms.ts similarity index 54% rename from javascripts/wrap-code-terms.js rename to javascripts/wrap-code-terms.ts index 0f6b171ad2..25b848512a 100644 --- a/javascripts/wrap-code-terms.js +++ b/javascripts/wrap-code-terms.ts @@ -11,20 +11,22 @@ export default function () { const codeTerms = document.querySelectorAll('#article-contents table code') if (!codeTerms) return - codeTerms.forEach(node => { + codeTerms.forEach((node) => { // Do the wrapping on the inner text only, so we don't modify hrefs - const oldText = escape(node.textContent) + const oldText = escape(node.textContent || '') const newText = oldText.replace(wordsLongerThan18Chars, (str) => { - return str - // GraphQL code terms use camelcase - .replace(camelCaseChars, '$1$2') - // REST code terms use underscores - // to keep word breaks looking nice, only break on underscores after the 12th char - // so `has_organization_projects` will break after `has_organization` instead of after `has_` - .replace(underscoresAfter12thChar, '$1_') - // Some Actions reference pages have tables with code terms separated by slashes - .replace(slashChars, '$1') + return ( + str + // GraphQL code terms use camelcase + .replace(camelCaseChars, '$1$2') + // REST code terms use underscores + // to keep word breaks looking nice, only break on underscores after the 12th char + // so `has_organization_projects` will break after `has_organization` instead of after `has_` + .replace(underscoresAfter12thChar, '$1_') + // Some Actions reference pages have tables with code terms separated by slashes + .replace(slashChars, '$1') + ) }) node.innerHTML = node.innerHTML.replace(oldText, newText) diff --git a/package-lock.json b/package-lock.json index 4c0bfbb083..41b9496059 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,9 +95,12 @@ "@graphql-tools/load": "^6.2.8", "@octokit/rest": "^18.5.3", "@types/github-slugger": "^1.3.0", + "@types/imurmurhash": "^0.1.1", + "@types/js-cookie": "^2.2.6", "@types/lodash": "^4.14.169", "@types/react": "^17.0.6", "@types/react-dom": "^17.0.5", + "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^4.26.1", "@typescript-eslint/parser": "^4.26.1", "async": "^3.2.0", @@ -158,6 +161,7 @@ "strip-ansi": "^7.0.0", "style-loader": "^2.0.0", "supertest": "^6.1.3", + "ts-loader": "^9.2.3", "typescript": "^4.3.2", "url-template": "^2.0.8", "webpack": "^5.37.0", @@ -4125,6 +4129,12 @@ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz", "integrity": "sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==" }, + "node_modules/@types/imurmurhash": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@types/imurmurhash/-/imurmurhash-0.1.1.tgz", + "integrity": "sha512-ThbETc7uxx6rIpNP0fE3bqrSSIeBWPrFY4TzY4WFsvdQYWinub+PLZV/9nT3zicRJJPWbmHqJIsHZHeh5Ad+Ug==", + "dev": true + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", @@ -4149,6 +4159,12 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/js-cookie": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.6.tgz", + "integrity": "sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.7", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", @@ -4290,6 +4306,12 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz", "integrity": "sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==" }, + "node_modules/@types/uuid": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz", + "integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==", + "dev": true + }, "node_modules/@types/yargs": { "version": "15.0.13", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.13.tgz", @@ -22754,6 +22776,74 @@ "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", "optional": true }, + "node_modules/ts-loader": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.2.3.tgz", + "integrity": "sha512-sEyWiU3JMHBL55CIeC4iqJQadI0U70A5af0kvgbNLHVNz2ACztQg0j/9x10bjjIht8WfFYLKfn4L6tkZ+pu+8Q==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-loader/node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-loader/node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-loader/node_modules/micromatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "dev": true, + "dependencies": { + "braces": "^3.0.1", + "picomatch": "^2.2.3" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ts-loader/node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/ts-pnp": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", @@ -28247,6 +28337,12 @@ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz", "integrity": "sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==" }, + "@types/imurmurhash": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@types/imurmurhash/-/imurmurhash-0.1.1.tgz", + "integrity": "sha512-ThbETc7uxx6rIpNP0fE3bqrSSIeBWPrFY4TzY4WFsvdQYWinub+PLZV/9nT3zicRJJPWbmHqJIsHZHeh5Ad+Ug==", + "dev": true + }, "@types/istanbul-lib-coverage": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", @@ -28271,6 +28367,12 @@ "@types/istanbul-lib-report": "*" } }, + "@types/js-cookie": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.6.tgz", + "integrity": "sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw==", + "dev": true + }, "@types/json-schema": { "version": "7.0.7", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", @@ -28412,6 +28514,12 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz", "integrity": "sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==" }, + "@types/uuid": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz", + "integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==", + "dev": true + }, "@types/yargs": { "version": "15.0.13", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.13.tgz", @@ -44031,6 +44139,57 @@ "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", "optional": true }, + "ts-loader": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.2.3.tgz", + "integrity": "sha512-sEyWiU3JMHBL55CIeC4iqJQadI0U70A5af0kvgbNLHVNz2ACztQg0j/9x10bjjIht8WfFYLKfn4L6tkZ+pu+8Q==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4" + }, + "dependencies": { + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "micromatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.2.3" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + } + } + }, "ts-pnp": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", diff --git a/package.json b/package.json index 6a8b7f787e..3d4248d662 100644 --- a/package.json +++ b/package.json @@ -101,9 +101,12 @@ "@graphql-tools/load": "^6.2.8", "@octokit/rest": "^18.5.3", "@types/github-slugger": "^1.3.0", + "@types/imurmurhash": "^0.1.1", + "@types/js-cookie": "^2.2.6", "@types/lodash": "^4.14.169", "@types/react": "^17.0.6", "@types/react-dom": "^17.0.5", + "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^4.26.1", "@typescript-eslint/parser": "^4.26.1", "async": "^3.2.0", @@ -164,6 +167,7 @@ "strip-ansi": "^7.0.0", "style-loader": "^2.0.0", "supertest": "^6.1.3", + "ts-loader": "^9.2.3", "typescript": "^4.3.2", "url-template": "^2.0.8", "webpack": "^5.37.0", diff --git a/tsconfig.json b/tsconfig.json index d994f47b99..b131f90623 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,16 @@ { "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, "forceConsistentCasingInFileNames": true, "noImplicitThis": false, - "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", @@ -15,8 +18,15 @@ "isolatedModules": true, "jsx": "preserve", "baseUrl": ".", + "noEmit": false, "allowSyntheticDefaultImports": true }, - "exclude": ["node_modules"], - "include": ["*.d.ts", "**/*.ts", "**/*.tsx"] + "exclude": [ + "node_modules" + ], + "include": [ + "*.d.ts", + "**/*.ts", + "**/*.tsx" + ] } diff --git a/webpack.config.js b/webpack.config.js index 5db31d2e15..c987f24d96 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,30 +6,22 @@ const { EnvironmentPlugin, ProvidePlugin } = require('webpack') module.exports = { mode: 'development', devtool: process.env.NODE_ENV === 'development' ? 'eval' : 'source-map', // no 'eval' outside of development - entry: './javascripts/index.js', + entry: './javascripts/index.ts', output: { filename: 'index.js', path: path.resolve(__dirname, 'dist'), publicPath: '/dist' }, stats: 'errors-only', + resolve: { + extensions: ['.tsx', '.ts', '.js', '.css', '.scss'] + }, module: { rules: [ { - test: /\.m?js$/, - exclude: /(node_modules)/, - use: { - loader: 'babel-loader', - options: { - exclude: /node_modules\/lodash/, - presets: [ - ['@babel/preset-env', { targets: '> 0.25%, not dead' }] - ], - plugins: [ - '@babel/transform-runtime' - ] - } - } + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/ }, { test: /\.css$/i,