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,