1
0
mirror of synced 2026-01-29 03:03:52 -05:00

Merge branch 'main' into repo-sync

This commit is contained in:
Octomerger Bot
2021-06-15 04:40:19 +10:00
committed by GitHub
43 changed files with 982 additions and 808 deletions

View File

@@ -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
})

View File

@@ -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,
})
}

View File

@@ -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')
})
}

View File

@@ -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')
})
}

View File

@@ -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)
})
}
}

View File

@@ -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)
})
)
}

View File

@@ -0,0 +1,3 @@
declare module 'browser-date-formatter' {
export default function browserDateFormatter(): void
}

View File

@@ -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(() => {

View File

@@ -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)
})
}
})
}

View File

@@ -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: <span class="platform-mac">inline content</span>
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'))
}

View File

@@ -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<HTMLElement>
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: <span class="platform-mac">inline content</span>
const platforms = Array.from(
document.querySelectorAll('.platform-mac, .platform-windows, .platform-linux')
) as Array<HTMLElement>
platforms.forEach((el) => {
detectPlatforms(el)
el.style.display = el.classList.contains('platform-' + platform) ? '' : 'none'
})
return Array.from(detectedPlatforms) as Array<string>
}
// hide links for any platform-specific sections that are not present
function hideSwitcherLinks(platformsInContent: Array<string>) {
const links = Array.from(
document.querySelectorAll('a.platform-switcher')
) as Array<HTMLAnchorElement>
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<HTMLAnchorElement> {
return Array.from(document.querySelectorAll('a.platform-switcher'))
}

View File

@@ -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'))
}

View File

@@ -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<HTMLElement>
if (!toolElements.length) return
const detectedTools = toolElements.flatMap((el) =>
Array.from(el.classList).filter((className) => supportedTools.includes(className))
) as Array<string>
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<HTMLElement>) {
// 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<string>) {
const links = Array.from(document.querySelectorAll('a.tool-switcher')) as Array<HTMLAnchorElement>
links.forEach((link) => {
if (detectedTools.includes(link.dataset.tool || '')) return
link.style.display = 'none'
})
}
function getDefaultTool(detectedTools: Array<string>): 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<HTMLAnchorElement>
}

View File

@@ -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
}

224
javascripts/events.ts Normal file
View File

@@ -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
}

View File

@@ -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,
})
}

View File

@@ -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)
}
}

16
javascripts/explorer.ts Normal file
View File

@@ -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)
}
}

View File

@@ -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<HTMLSelectElement>
const cards = Array.from(document.querySelectorAll('.js-filter-card')) as Array<HTMLElement>
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<boolean> = []
// 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')
}
})
}

View File

@@ -1,6 +0,0 @@
export default function getCsrf () {
const csrfEl = document
.querySelector('meta[name="csrf-token"]')
if (!csrfEl) return ''
return csrfEl.getAttribute('content')
}

5
javascripts/get-csrf.ts Normal file
View File

@@ -0,0 +1,5 @@
export default function getCsrf() {
const csrfEl = document.querySelector('meta[name="csrf-token"]')
if (!csrfEl) return ''
return csrfEl.getAttribute('content')
}

View File

@@ -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<any>) {
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<any>) {
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<any>) => h(tagName, ...args),
])
)

View File

@@ -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('/')

View File

@@ -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 })
}
}

View File

@@ -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)
})
}

View File

@@ -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)
})
}

View File

@@ -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',
})
})

View File

@@ -0,0 +1,3 @@
declare module 'search-with-your-keyboard' {
export default function searchWithYourKeyboard(inputSelector: string, hitsSelector: string): void
}

View File

@@ -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 <meta> 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 <meta> 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<string>) {
let languageCode = location.pathname.split('/')[1]
if (!languageCodes.includes(languageCode)) languageCode = 'en'
return languageCode
}
function deriveVersionFromPath (allVersions, nonEnterpriseDefaultVersion) {
function deriveVersionFromPath(
allVersions: Record<string, string>,
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<any>) => {
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<SearchResult>) {
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))
}

View File

@@ -1,3 +1,4 @@
export default function setNextEnv () {
export default function setNextEnv() {
// @ts-ignore
window.IS_NEXTJS_PAGE = !!document.querySelector('#__next')
}

View File

@@ -10,20 +10,21 @@
* <div class="js-show-more-item d-none">hidden item</div>
* <button class="js-show-more-button" data-js-show-more-items="1">show one more item</button>
* </div>
*/
*/
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)
}
})
}
})
}

View File

@@ -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()
}
}
}

View File

@@ -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()
})
})
}

69
javascripts/survey.ts Normal file
View File

@@ -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<HTMLElement>
allEls.forEach(hideElement)
const stateEls = Array.from(form.querySelectorAll(stateSelector)) as Array<HTMLElement>
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<HTMLElement>
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()
})
})
}

View File

@@ -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<HTMLImageElement>, 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'
}
}

View File

@@ -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 }
}

View File

@@ -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<wbr>$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_<wbr>')
// Some Actions reference pages have tables with code terms separated by slashes
.replace(slashChars, '$1<wbr>')
return (
str
// GraphQL code terms use camelcase
.replace(camelCaseChars, '$1<wbr>$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_<wbr>')
// Some Actions reference pages have tables with code terms separated by slashes
.replace(slashChars, '$1<wbr>')
)
})
node.innerHTML = node.innerHTML.replace(oldText, newText)

159
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
]
}

View File

@@ -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,