1
0
mirror of synced 2025-12-19 18:10:59 -05:00

Remove domain edit feature code (#55991)

This commit is contained in:
Kevin Heis
2025-06-10 08:55:25 -07:00
committed by GitHub
parent d57fbd6f88
commit 7ebc8876f4
25 changed files with 92 additions and 1047 deletions

View File

@@ -75,7 +75,6 @@ jobs:
- shielding
# - tests
# - tools
- tracking
- versions
- webhooks
- workflows

View File

@@ -320,17 +320,7 @@ alerts:
WARNING: Warning
TIP: Tip
CAUTION: Caution
domain_edit:
name: Domain name
edit: Edit
edit_your: Edit your domain name
experimental: Experimental
your_name: Your domain name
cancel: Cancel
save: Save
snippet_about: Updating will include the new domain name in all code snippets across GitHub Docs.
learn_more: Learn more
submission_failed: Submission failed. Please try again in a minute.
cookbook_landing:
spotlight: Spotlight
explore_articles: Explore {{ number }} prompt articles

View File

@@ -27,7 +27,6 @@ import wrapProceduralImages from './wrap-procedural-images.js'
import parseInfoString from './parse-info-string.js'
import annotate from './annotate.js'
import alerts from './alerts.js'
import replaceDomain from './replace-domain.js'
import removeHtmlComments from 'remark-remove-comments'
import remarkStringify from 'remark-stringify'
@@ -49,7 +48,6 @@ export function createProcessor(context) {
.use(headingLinks)
.use(codeHeader)
.use(annotate)
.use(replaceDomain)
.use(highlight, {
languages: { ...common, graphql, dockerfile, http, groovy, erb, powershell },
subset: false,

View File

@@ -1,37 +0,0 @@
/**
* This makes it so that the `github.com` or `HOSTNAME` in a code snippet
* becomes replacable.
*/
import { visit } from 'unist-util-visit'
// Don't use `g` on these regexes
const VALID_REPLACEMENTS = [[/\bHOSTNAME\b/, 'HOSTNAME']]
const CODE_FENCE_KEYWORD = 'replacedomain'
const matcher = (node) => {
return (
node.type === 'element' &&
node.tagName === 'pre' &&
node.children[0]?.data?.meta[CODE_FENCE_KEYWORD]
)
}
export default function alerts() {
return (tree) => {
visit(tree, matcher, (node) => {
const code = node.children[0].children[0].value
for (const [regex, replacement] of VALID_REPLACEMENTS) {
if (regex.test(code)) {
const codeTag = node.children[0]
const replacements = codeTag.properties['data-replacedomain'] || []
if (!replacements.includes(replacement)) {
replacements.push(replacement)
codeTag.properties['data-replacedomain'] = replacements
}
}
}
})
}
}

View File

@@ -10,6 +10,5 @@ children:
- /permissions
- /code-annotations
- /alerts
- /replace-domain
- /html-comments
---

View File

@@ -1,49 +0,0 @@
---
title: Replace domain
intro: This demonstrates code snippets that have host names that can be replaced.
versions:
fpt: '*'
ghes: '*'
ghec: '*'
type: how_to
---
## Overview
If you have an article with code snippets that have the `replacedomain`
annotation on its code fence, that means the page *might* take the current
user's cookie (indicating their personal hostname) and replace that within
the code snippet.
## Shell code snippet (on)
```sh replacedomain
curl https://HOSTNAME/api/v1
```
## Shell code snippet (off)
```sh
curl https://HOSTNAME/api/v2
```
## JavaScript code snippet (on)
```js replacedomain
await fetch("https://HOSTNAME/api/v1")
```
## JavaScript code snippet (off)
```js
await fetch("https://HOSTNAME/api/v2")
```
## Not always there
In this next code snippet, the `HOSTNAME` only appears if the current
version is `ghes`. That should be fine.
```text replacedomain copy
ssh handle@{% ifversion ghes %}HOSTNAME{% else %}github.com{% endif %}
```

View File

@@ -320,17 +320,7 @@ alerts:
WARNING: Warning
TIP: Tip
CAUTION: Caution
domain_edit:
name: Domain name
edit: Edit
edit_your: Edit your domain name
experimental: Experimental
your_name: Your domain name
cancel: Cancel
save: Save
snippet_about: Updating will include the new domain name in all code snippets across GitHub Docs.
learn_more: Learn more
submission_failed: Submission failed. Please try again in a minute.
cookbook_landing:
spotlight: Spotlight
explore_articles: Explore {{ number }} prompt articles

View File

@@ -844,35 +844,3 @@ test.describe('translations', () => {
await expect(page).toHaveURL('/ja/get-started/start-your-journey/hello-world')
})
})
test.describe('view pages with custom domain cookie', () => {
test('view article page', async ({ page }) => {
await page.goto(
'/enterprise-server@latest/get-started/markdown/replace-domain?ghdomain=example.ghe.com',
)
const content = page.locator('pre')
await expect(content.nth(0)).toHaveText(/curl https:\/\/example.ghe.com\/api\/v1/)
await expect(content.nth(1)).toHaveText(/curl https:\/\/HOSTNAME\/api\/v2/)
await expect(content.nth(2)).toHaveText('await fetch("https://example.ghe.com/api/v1")')
await expect(content.nth(3)).toHaveText('await fetch("https://HOSTNAME/api/v2")')
// Now switch to enterprise-cloud, where replacedomain should not be used
await page.getByLabel('Select GitHub product version').click()
await page.getByLabel('Enterprise Cloud', { exact: true }).click()
await expect(content.nth(0)).toHaveText(/curl https:\/\/HOSTNAME\/api\/v1/)
await expect(content.nth(1)).toHaveText(/curl https:\/\/HOSTNAME\/api\/v2/)
await expect(content.nth(2)).toHaveText('await fetch("https://HOSTNAME/api/v1")')
await expect(content.nth(3)).toHaveText('await fetch("https://HOSTNAME/api/v2")')
// Again switch back to enterprise server again
await page.getByLabel('Select GitHub product version').click()
await page.getByLabel('Enterprise Server 3.').first().click()
await expect(content.nth(0)).toHaveText(/curl https:\/\/example.ghe.com\/api\/v1/)
await expect(content.nth(1)).toHaveText(/curl https:\/\/HOSTNAME\/api\/v2/)
await expect(content.nth(2)).toHaveText('await fetch("https://example.ghe.com/api/v1")')
await expect(content.nth(3)).toHaveText('await fetch("https://HOSTNAME/api/v2")')
})
})

View File

@@ -13,7 +13,6 @@ import { useTranslation } from '@/languages/components/useTranslation'
import { Breadcrumbs } from '@/frame/components/page-header/Breadcrumbs'
import { useLanguages } from '@/languages/components/LanguagesContext'
import { ClientSideLanguageRedirect } from './ClientSideLanguageRedirect'
import { DomainNameEditProvider } from '@/links/components/useEditableDomainContext'
import { SearchOverlayContextProvider } from '@/search/components/context/SearchOverlayContext'
const MINIMAL_RENDER = Boolean(JSON.parse(process.env.MINIMAL_RENDER || 'false'))
@@ -76,7 +75,6 @@ export const DefaultLayout = (props: Props) => {
}
return (
<DomainNameEditProvider>
<SearchOverlayContextProvider>
<Head>
{error === '404' ? (
@@ -169,6 +167,5 @@ export const DefaultLayout = (props: Props) => {
</div>
</div>
</SearchOverlayContextProvider>
</DomainNameEditProvider>
)
}

View File

@@ -21,7 +21,6 @@ import { Breadcrumbs } from '@/frame/components/page-header/Breadcrumbs'
import { Link } from '@/frame/components/Link'
import { useTranslation } from '@/languages/components/useTranslation'
import { LinkPreviewPopover } from '@/links/components/LinkPreviewPopover'
import { ReplaceDomain } from '@/links/components/replace-domain'
const ClientSideRefresh = dynamic(() => import('@/frame/components/ClientSideRefresh'), {
ssr: false,
@@ -104,7 +103,6 @@ export const ArticlePage = () => {
<LinkPreviewPopover />
{isDev && <ClientSideRefresh />}
{router.pathname.includes('/rest/') && <RestRedirect />}
<ReplaceDomain />
{currentLayout === 'inline' ? (
<>
<ArticleInlineLayout

View File

@@ -147,7 +147,6 @@ const DEFAULT_UI_NAMESPACES = [
'contribution_cta',
'support',
'rest',
'domain_edit',
'cookbook_landing',
]

View File

@@ -1,11 +1,9 @@
import { Suspense } from 'react'
import cx from 'classnames'
import { KebabHorizontalIcon, LinkExternalIcon } from '@primer/octicons-react'
import { IconButton, ActionMenu, ActionList } from '@primer/react'
import { LanguagePicker } from '@/languages/components/LanguagePicker'
import { useTranslation } from '@/languages/components/useTranslation'
import DomainNameEdit from '@/links/components/DomainNameEdit'
import { VersionPicker } from '@/versions/components/VersionPicker'
import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion'
import { useHasAccount } from '../hooks/useHasAccount'
@@ -26,8 +24,6 @@ export function HeaderSearchAndWidgets({ width, isSearchOpen, SearchButton }: Pr
hasAccount === false && // don't show if `null`
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
const showDomainNameEdit = currentVersion.startsWith('enterprise-server@')
return (
<>
<div className={cx('d-flex flex-items-center', isSearchOpen && 'd-none')}>
@@ -105,14 +101,6 @@ export function HeaderSearchAndWidgets({ width, isSearchOpen, SearchButton }: Pr
<>
<VersionPicker xs={true} />
<ActionList.Divider />
{showDomainNameEdit && (
<>
<Suspense>
<DomainNameEdit xs={true} />
</Suspense>
<ActionList.Divider />
</>
)}
</>
)}
{signupCTAVisible && (

View File

@@ -1,11 +1,9 @@
import { Suspense } from 'react'
import cx from 'classnames'
import { SearchIcon, XIcon, KebabHorizontalIcon, LinkExternalIcon } from '@primer/octicons-react'
import { IconButton, ActionMenu, ActionList } from '@primer/react'
import { LanguagePicker } from '@/languages/components/LanguagePicker'
import { useTranslation } from '@/languages/components/useTranslation'
import DomainNameEdit from '@/links/components/DomainNameEdit'
import { OldSearchInput } from '@/search/components/input/OldSearchInput'
import { VersionPicker } from '@/versions/components/VersionPicker'
import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion'
@@ -29,8 +27,6 @@ export function OldHeaderSearchAndWidgets({ isSearchOpen, setIsSearchOpen, width
hasAccount === false && // don't show if `null`
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
const showDomainNameEdit = currentVersion.startsWith('enterprise-server@')
return (
<div className={cx('d-flex flex-items-center', isSearchOpen && styles.widgetsContainer)}>
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
@@ -151,14 +147,6 @@ export function OldHeaderSearchAndWidgets({ isSearchOpen, setIsSearchOpen, width
<>
<VersionPicker xs={true} />
<ActionList.Divider />
{showDomainNameEdit && (
<>
<Suspense>
<DomainNameEdit xs={true} />
</Suspense>
<ActionList.Divider />
</>
)}
</>
)}
{signupCTAVisible && (

View File

@@ -62,7 +62,6 @@ import mockVaPortal from './mock-va-portal'
import dynamicAssets from '@/assets/middleware/dynamic-assets'
import generalSearchMiddleware from '@/search/middleware/general-search-middleware'
import shielding from '@/shielding/middleware'
import tracking from '@/tracking/middleware'
import { MAX_REQUEST_TIMEOUT } from '@/frame/lib/constants.js'
const { NODE_ENV } = process.env
@@ -200,7 +199,6 @@ export default function (app: Express) {
}
// ** Possible early exits after cookies **
app.use(tracking)
// *** Headers ***
app.set('etag', false) // We will manage our own ETags if desired

View File

@@ -14,4 +14,3 @@
@import "src/content-render/stylesheets/index.scss";
@import "src/links/stylesheets/hover-card.scss";
@import "src/links/stylesheets/domain-edit.scss";

View File

@@ -1,305 +0,0 @@
import { useEffect, useState, useRef } from 'react'
import { useRouter } from 'next/router'
import { BeakerIcon } from '@primer/octicons-react'
import { useTranslation } from '@/languages/components/useTranslation'
import { Box, Flash, FormControl, Spinner, TextInput } from '@primer/react'
import { Dialog } from '@primer/react/experimental'
import { useEditableDomainName } from './useEditableDomainContext'
import { sendEvent } from '@/events/components/events'
import { EventType } from '@/events/types'
type Props = {
xs?: boolean
}
const EXPERIMENT_NAME = 'domain_edit'
const QUERY_STRING_KEY = 'ghdomain' // Must match the middleware
const TRANSLATION_NAMESPACE = 'domain_edit'
export default function DomainNameEdit({ xs }: Props) {
const { t } = useTranslation(TRANSLATION_NAMESPACE)
const { asPath } = useRouter()
const { domainName, setDomainName } = useEditableDomainName()
const [localName, setLocalName] = useState('')
useEffect(() => {
setLocalName(domainName)
}, [domainName])
const [open, setOpen] = useState(false)
useEffect(() => {
function handler(event: MouseEvent) {
if (event.target) {
const target = event.target as HTMLElement | SVGSVGElement
if (
(target.tagName === 'BUTTON' && target.classList.contains('replacedomain-edit')) ||
(target.tagName === 'SPAN' && target.classList.contains('replacedomain-text')) ||
(target.tagName === 'svg' &&
target.parentElement &&
target.parentElement.classList.contains('replacedomain-edit')) ||
(target.tagName === 'path' &&
target.parentElement &&
target.parentElement.parentElement &&
target.parentElement.parentElement.classList.contains('replacedomain-edit'))
) {
setOpen(true)
sendEvent({
type: EventType.experiment,
experiment_name: EXPERIMENT_NAME,
experiment_variation: 'opened',
experiment_success: true,
})
}
}
}
const main = document.querySelector<HTMLDivElement>('#main-content')
if (main) {
main.addEventListener('click', handler)
}
return () => {
if (main) {
main.removeEventListener('click', handler)
}
}
}, [asPath])
useEffect(() => {
if (document.querySelectorAll('code[data-replacedomain]').length > 0) {
sendEvent({
type: EventType.experiment,
experiment_name: EXPERIMENT_NAME,
experiment_variation: 'available',
experiment_success: true,
})
}
}, [asPath])
const nameInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
if (nameInputRef.current) {
nameInputRef.current.focus()
}
}
}, [open])
const [submissionFailed, setSubmissionFailed] = useState(false)
const [loading, setLoading] = useState(false)
function handlSubmit(name: string) {
const searchParams = new URLSearchParams({ [QUERY_STRING_KEY]: name })
setLoading(true)
fetch(`/__tracking__?${searchParams.toString()}`)
.then((response) => {
if (response.ok) {
setOpen(false)
setSubmissionFailed(false)
setDomainName(localName.trim().toLowerCase())
} else {
setSubmissionFailed(true)
}
sendEvent({
type: EventType.experiment,
experiment_name: EXPERIMENT_NAME,
experiment_variation: 'saved',
experiment_success: true,
})
})
.finally(() => {
setLoading(false)
})
}
const validationError = getValidationError(localName)
return (
<div data-testid="domain-name-edit" className={xs ? 'd-flex' : ''}>
{open && (
<Dialog
title={
<>
{t('edit_your')}{' '}
<span style={{ marginLeft: 15 }}>
<BeakerIcon size={18} /> {t('experimental')}
</span>
</>
}
width="large"
footerButtons={[
{ buttonType: 'default', content: t('cancel'), onClick: () => setOpen(false) },
{
buttonType: 'primary',
type: 'button',
onClick: () => {
handlSubmit(localName.trim())
},
content: loading ? (
<>
<Spinner size="small" /> {t('save')}
</>
) : (
t('save')
),
disabled: !!validationError || loading,
},
]}
onClose={() => {
setOpen(false)
sendEvent({
type: EventType.experiment,
experiment_name: EXPERIMENT_NAME,
experiment_variation: 'closed',
experiment_success: true,
})
}}
aria-labelledby="header"
>
<form
data-testid="domain-name-edit-form"
onSubmit={(event) => {
event.preventDefault()
if (!validationError) {
handlSubmit(localName.trim())
}
}}
>
<Box sx={{ p: 3 }}>
<FormControl>
<FormControl.Label>{t('name')}</FormControl.Label>
<TextInput
value={localName}
ref={nameInputRef}
aria-label={t('your_name')}
placeholder="github.fabrikam.com"
onChange={(event) => setLocalName(event.target.value)}
validationStatus={localName.trim() && validationError ? 'error' : undefined}
sx={{ width: '100%' }}
/>
{localName.trim() && validationError && (
<FormControl.Validation variant="error">{validationError}</FormControl.Validation>
)}
</FormControl>
</Box>
<SubmissionError error={submissionFailed} />
<LearnMoreSnippet />
</form>
</Dialog>
)}
{/* Deliberately commented out until we decide to include this on all pages */}
{/* <DisplayAndToggle
xs={xs}
domainNames={domainNames}
trigger={() => setOpen(true)}
returnFocusRef={returnFocusRef}
/> */}
</div>
)
}
function getValidationError(domainName: string) {
const clean = domainName.trim().toLowerCase()
if (/\s/.test(clean)) {
return 'Whitespace'
}
// if (clean === 'hostname' || !clean) {
// return 'Empty'
// }
if (clean === 'github.com' || clean === 'api.github.com') {
return "Can't be github.com"
}
return null
}
function SubmissionError({ error }: { error: boolean }) {
const { t } = useTranslation(TRANSLATION_NAMESPACE)
if (error) {
return (
<Flash variant="danger">
<p>{t('submission_failed')}</p>
</Flash>
)
}
return null
}
/* Deliberately commented out until we decide to include this on all pages */
// function DisplayAndToggle({
// xs,
// domainNames,
// trigger,
// returnFocusRef,
// }: {
// xs?: boolean
// domainNames: string[]
// trigger: () => void
// returnFocusRef: React.RefObject<HTMLButtonElement>
// }) {
// const { t } = useTranslation(TRANSLATION_NAMESPACE)
// return (
// <Box
// sx={{
// display: !xs ? ['none', null, 'flex'] : undefined,
// gap: '2',
// alignItems: 'center',
// ml: 2,
// position: 'relative',
// }}
// >
// <Text
// sx={{
// fontSize: '1',
// color: 'fg.muted',
// fontWeight: 'semibold',
// }}
// >
// <Text
// sx={{
// color: 'fg.default',
// }}
// >
// {t('name')}:
// </Text>{' '}
// <code>{domainNames.length ? domainNames[0] : DEFAULT}</code>
// </Text>
// <Button
// variant="invisible"
// size="small"
// sx={{
// display: 'absolute',
// right: 0,
// }}
// ref={returnFocusRef}
// onClick={() => {
// trigger()
// }}
// >
// {t('edit')}
// </Button>
// </Box>
// )
// }
function LearnMoreSnippet() {
const { t } = useTranslation(TRANSLATION_NAMESPACE)
return (
<Box sx={{ p: 3 }}>
<p>
{t('snippet_about')}{' '}
<a href="/enterprise-server@latest/early-access/admin/articles/editing-host-names-in-github-docs">
{t('learn_more')}
</a>
</p>
</Box>
)
}

View File

@@ -1,20 +0,0 @@
export function createPenSVG(): SVGSVGElement {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('focusable', 'false')
svg.setAttribute('role', 'img')
svg.setAttribute('viewBox', '0 0 16 16')
svg.setAttribute('width', '16')
svg.setAttribute('height', '16')
svg.setAttribute('fill', 'currentColor')
svg.setAttribute(
'style',
'display: inline-block; user-select: none; vertical-align: text-bottom; overflow: visible; color: var(--color-accent-fg); text-decoration: dashed underline;',
)
const PEN_PATH = `M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z`
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', PEN_PATH)
svg.appendChild(path)
return svg
}

View File

@@ -1,131 +0,0 @@
import { useEffect } from 'react'
import { useRouter } from 'next/router'
import { useVersion } from '@/versions/components/useVersion'
import { useEditableDomainName } from './useEditableDomainContext'
import { createPenSVG } from './pen-icon'
// We only want to activate the replace-domain feature for these versions.
// This means that if you're on a version we don't want it activated on,
// even though you have a your-domain cookie, it *won't* replace the
// word HOSTNAME.
const REPLACEDOMAIN_VERSION_PREFIXES = ['enterprise-server@']
function replaceDomains(domain: string | null) {
document.querySelectorAll<HTMLElement>('pre code[data-replacedomain]').forEach((codeBlock) => {
const replaceDomain = codeBlock.dataset.replacedomain
if (!replaceDomain) return
const replaceDomains = replaceDomain.split(/\s/)
const spans = codeBlock.querySelectorAll<HTMLSpanElement>('span[class*="-string"]')
if (spans.length) {
spans.forEach((span) => {
replaceInTextContent(span, replaceDomains, domain)
})
} else {
replaceInTextContent(codeBlock, replaceDomains, domain)
}
replaceInClipboard(codeBlock, replaceDomains, domain)
})
}
function replaceInClipboard(element: HTMLElement, replaceDomains: string[], domain: string | null) {
if (
element.parentElement &&
element.parentElement.parentElement &&
element.parentElement.parentElement.classList.contains('code-example')
) {
const pre =
element.parentElement.parentElement.querySelector<HTMLPreElement>('pre[data-clipboard]')
const regex = new RegExp(`(${replaceDomains.join('|')})`)
if (pre && pre.textContent) {
if (!pre.dataset.originalText) {
pre.dataset.originalText = pre.textContent
}
if (domain) {
pre.textContent = pre.dataset.originalText.replace(regex, domain)
} else {
pre.textContent = pre.dataset.originalText
}
}
}
}
function replaceInTextContent(
element: HTMLElement,
replaceDomains: string[],
domain: string | null,
) {
if (!element.textContent) return
if (!element.querySelector('.replacedomain-text')) {
splitElementText(element, replaceDomains)
}
if (domain !== null) {
element.querySelectorAll('.replacedomain-text').forEach((textSpan) => {
textSpan.textContent = domain
textSpan.classList.add('editable-domain')
})
element.querySelectorAll('.replacedomain-edit').forEach((toggleElement) => {
toggleElement.classList.remove('visually-hidden')
})
} else {
element.querySelectorAll('.replacedomain-text').forEach((textSpan) => {
if (element.dataset.replacedomain) {
textSpan.textContent = element.dataset.replacedomain
}
textSpan.classList.remove('editable-domain')
})
element.querySelectorAll('.replacedomain-edit').forEach((toggleElement) => {
toggleElement.classList.remove('visually-hidden')
})
}
}
function splitElementText(element: HTMLElement, replaceDomains: string[]) {
const splitText = element.textContent!.split(new RegExp(`(${replaceDomains.join('|')})`))
element.textContent = ''
for (const text of splitText) {
if (replaceDomains.includes(text)) {
element.appendChild(createEditWrapper(text))
} else {
const span = document.createElement('span')
span.textContent = text
element.appendChild(span)
}
}
}
function createEditWrapper(text: string): HTMLSpanElement {
const element = document.createElement('span')
element.classList.add('replacedomain-edit')
const span = document.createElement('span')
span.classList.add('replacedomain-text')
span.textContent = text
element.appendChild(span)
element.appendChild(createPenSVG())
return element
}
export function ReplaceDomain() {
const { asPath } = useRouter()
const { domainName } = useEditableDomainName()
const { currentVersion } = useVersion()
const enable = REPLACEDOMAIN_VERSION_PREFIXES.some((prefix) => currentVersion.startsWith(prefix))
useEffect(() => {
if (domainName) {
if (enable) {
replaceDomains(domainName.split(',')[0])
} else {
replaceDomains(null)
}
} else if (enable) {
replaceDomains(null)
}
}, [asPath, enable, domainName])
return null
}

View File

@@ -1,38 +0,0 @@
import { useContext, useEffect, createContext, useState } from 'react'
import Cookies from 'js-cookie'
const COOKIE_KEY = 'github_domains'
const DEFAULT = ''
type DomainNameEdit = {
domainName: string
setDomainName: (value: string) => void
}
const UseEditableDomainContext = createContext<DomainNameEdit>({
domainName: '',
setDomainName: () => {},
})
export function DomainNameEditProvider({ children }: { children: React.ReactNode }) {
const [name, setName] = useState(DEFAULT)
useEffect(() => {
const cookie = Cookies.get(COOKIE_KEY)
if (cookie) {
setName(cookie.split(',')[0])
}
}, [])
return (
<UseEditableDomainContext.Provider value={{ domainName: name, setDomainName: setName }}>
{children}
</UseEditableDomainContext.Provider>
)
}
export const useEditableDomainName = () => {
const context = useContext(UseEditableDomainContext)
if (context === undefined) {
throw new Error('useEditableDomainName must be inside a DomainNameEditProvider')
}
return context
}

View File

@@ -1,38 +0,0 @@
span.replacedomain-edit {
color: var(--fgColor-accent, var(--color-accent-fg, #0969da));
font-weight: 600;
border-width: 1px;
border-style: solid solid dashed;
border-image: initial;
border-top-color: transparent;
border-right-color: transparent;
border-bottom-color: var(
--borderColor-default,
var(--color-border-default, #d0d7de)
);
border-left-color: transparent;
padding-left: 2px;
padding-right: 2px;
cursor: pointer;
:hover {
border-color: var(
--borderColor-default,
var(--color-border-default, #d0d7de)
);
background-color: var(
--bgColor-default,
var(--color-canvas-default, #ffffff)
);
}
svg {
margin: 0 3px;
}
}
span.editable-domain {
color: var(--color-accent-fg);
cursor: pointer;
// text-decoration: dashed underline;
}

View File

@@ -37,7 +37,7 @@ const RECOGNIZED_KEYS_BY_ANY = new Set([
'search-overlay-ask-ai',
// The drop-downs on "Webhook events and payloads"
'actionType',
// Used by the tracking middleware
// Legacy domain tracking parameter (no longer processed but still recognized)
'ghdomain',
// UTM campaign tracking
'utm_source',

View File

@@ -1,20 +0,0 @@
# Tracking
## Overview
This is about recording inbound links that helps with "tracking".
For example, if you arrive on Docs with `?ghdomain=example.ghe.com` we
can pick that up and put it in a cookie so that the user's content, when
they view it, can say `curl https://example.ghe.com/api/v1` instead
of the stock `curl https://HOSTNAME/api/v1`.
## How it works
For a certain number of query strings, we "snatch them up" and redirect
to the same URL as you were on but with the query string key removed.
And in the 302 Found response, we might include a `set-cookie`.
## Notes
none

View File

@@ -1,86 +0,0 @@
import type { Response, NextFunction } from 'express'
import type { ExtendedRequest } from '@/types'
import statsd from '@/observability/lib/statsd.js'
import { noCacheControl } from '@/frame/middleware/cache-control.js'
const STATSD_KEY = 'middleware.handle_tracking_querystrings'
// Exported for the sake of end-to-end tests
export const DOMAIN_QUERY_PARAM = 'ghdomain'
export const MAX_DOMAINS_SAVED = 3
const DOMAIN_COOKIE_AGE_MS = 365 * 24 * 3600 * 1000
export const DOMAIN_COOKIE_NAME = 'github_domains'
export default function handleTrackingQueryStrings(
req: ExtendedRequest,
res: Response,
next: NextFunction,
) {
if (req.path.startsWith('/_next/')) {
return next()
}
if (req.query[DOMAIN_QUERY_PARAM] || req.query[DOMAIN_QUERY_PARAM] === '') {
if (Array.isArray(req.query[DOMAIN_QUERY_PARAM])) {
res.status(400).send('can only be one')
const tags = [`key:${DOMAIN_QUERY_PARAM}`, 'domain:_multiple_']
statsd.increment(STATSD_KEY, 1, tags)
return
}
const searchParams = new URLSearchParams(req.query as any)
const oldCookieValue: string = req.cookies[DOMAIN_COOKIE_NAME] || ''
const oldCookieValueParsed = oldCookieValue
.split(',')
.map((x) => x.trim().toLowerCase())
.filter(Boolean)
const domain = (searchParams.get(DOMAIN_QUERY_PARAM) || '').toLowerCase().trim()
if (domain) {
const newCookieValue = [domain, ...oldCookieValueParsed.filter((x) => x !== domain)]
.slice(0, MAX_DOMAINS_SAVED)
.join(',')
res.cookie(DOMAIN_COOKIE_NAME, newCookieValue, {
maxAge: DOMAIN_COOKIE_AGE_MS,
httpOnly: false,
})
} else {
res.clearCookie(DOMAIN_COOKIE_NAME)
}
searchParams.delete(DOMAIN_QUERY_PARAM)
noCacheControl(res)
let newURL = req.path
if (searchParams.toString()) {
newURL += `?${searchParams.toString()}`
}
// Ordinarily, you can put the query string on any URL and the server
// will just 302 redirect you to the same URL but with the query string
// key removed. However, when we, from the client-side UI, send a
// fetch() event, as an XHR request, we can't follow the redirect.
// So we have this "dummy" endpoint just to be able return a 200 OK.
if (req.path === '/__tracking__') {
res.send('OK')
} else {
res.redirect(302, newURL)
}
const tags = [`key:${DOMAIN_QUERY_PARAM}`, `domain:${domain || '_empty_'}`]
statsd.increment(STATSD_KEY, 1, tags)
return
} else if (req.path === '/__tracking__') {
// E.g. `GET /__tracking__` but not the lack of query string
return res.status(400).type('text').send('Lacking query string')
}
return next()
}

View File

@@ -1,9 +0,0 @@
import express from 'express'
import handleTrackingQueryStrings from './handle-query-strings.js'
const router = express.Router()
router.use(handleTrackingQueryStrings)
export default router

View File

@@ -1,133 +0,0 @@
import { describe, expect, test } from 'vitest'
import { get } from '@/tests/helpers/e2etest.js'
import {
DOMAIN_QUERY_PARAM,
DOMAIN_COOKIE_NAME,
MAX_DOMAINS_SAVED,
} from '../middleware/handle-query-strings.js'
describe('setting a cookie', () => {
test('on home page', async () => {
const res = await get(`/en?${DOMAIN_QUERY_PARAM}=acme.example.com`)
expect(res.statusCode).toBe(302)
const setCookie = res.headers['set-cookie'][0]
expect(setCookie).toMatch(/github_domains=acme.example.com/)
expect(res.headers.location).toBe('/en')
expect(res.headers['cache-control']).toMatch(/private/)
expect(res.headers['cache-control']).toMatch(/max-age=0/)
})
test('with other query string things', async () => {
const res = await get(`/en?${DOMAIN_QUERY_PARAM}=acme.example.com&foo=bar`)
expect(res.statusCode).toBe(302)
const setCookie = res.headers['set-cookie'][0]
expect(setCookie).toMatch(/github_domains=acme.example.com/)
expect(res.headers.location).toBe('/en?foo=bar')
})
test('always lowercase', async () => {
const res = await get(`/en?${DOMAIN_QUERY_PARAM}=Acme.example.COM`)
expect(res.statusCode).toBe(302)
const setCookie = res.headers['set-cookie'][0]
expect(setCookie).toMatch(/github_domains=acme.example.com/)
})
test('on root page', async () => {
const res = await get(`/?${DOMAIN_QUERY_PARAM}=acme.example.com`)
expect(res.statusCode).toBe(302)
const setCookie = res.headers['set-cookie'][0]
expect(setCookie).toMatch(/github_domains=acme.example.com/)
expect(res.headers.location).toBe('/')
})
test('empty value does nothing if nothing previous', async () => {
const res = await get(`/?${DOMAIN_QUERY_PARAM}=`)
expect(res.statusCode).toBe(302)
expect(res.headers['set-cookie'][0]).toMatch(`${DOMAIN_COOKIE_NAME}=;`)
})
test('empty value, when trimmed, does nothing if nothing previous', async () => {
const res = await get(`/?${DOMAIN_QUERY_PARAM}=%20`)
expect(res.statusCode).toBe(302)
expect(res.headers['set-cookie'][0]).toMatch(`${DOMAIN_COOKIE_NAME}=;`)
})
test('empty value resets previous cookie', async () => {
const res = await get(`/?${DOMAIN_QUERY_PARAM}=`, {
headers: {
cookie: `${DOMAIN_COOKIE_NAME}=acme.example.com`,
},
})
expect(res.statusCode).toBe(302)
const setCookie = res.headers['set-cookie'][0]
expect(setCookie).toMatch(/github_domains=;/)
})
test('append with previous', async () => {
const res = await get(`/?${DOMAIN_QUERY_PARAM}=next.example.com`, {
headers: {
cookie: `${DOMAIN_COOKIE_NAME}=previous.example.com`,
},
})
expect(res.statusCode).toBe(302)
const setCookie = res.headers['set-cookie'][0]
// %2C is a comma
expect(setCookie).toMatch(/github_domains=next.example.com%2Cprevious.example.com;/)
})
test('append with too many', async () => {
let cookie = ''
for (const letter of Array.from('abcdef')) {
const next = `${letter}.example.com`
const res = await get(`/?${DOMAIN_QUERY_PARAM}=${next}`, {
headers: { cookie },
})
const setCookie: string = res.headers['set-cookie'][0]
cookie = setCookie.split(';').filter((x) => x.startsWith(DOMAIN_COOKIE_NAME))[0]
if (letter === 'a') {
// first
expect(cookie).toBe(`${DOMAIN_COOKIE_NAME}=a.example.com`)
} else if (letter === 'f') {
// last
expect(cookie.split('%2C').length).toBe(MAX_DOMAINS_SAVED)
expect(cookie.startsWith(`${DOMAIN_COOKIE_NAME}=f.example.com`)).toBe(true)
}
}
})
test('append with same as before', async () => {
const res = await get(`/?${DOMAIN_QUERY_PARAM}=Acme.example.com`, {
headers: {
cookie: `${DOMAIN_COOKIE_NAME}=acme.example.com`,
},
})
expect(res.statusCode).toBe(302)
const setCookie = res.headers['set-cookie'][0]
expect(setCookie).toMatch(/github_domains=acme.example.com;/)
})
test('trying to set multiple', async () => {
const res = await get(
`/?${DOMAIN_QUERY_PARAM}=a.example.com&${DOMAIN_QUERY_PARAM}=b.example.com`,
)
expect(res.statusCode).toBe(400)
expect(res.body).toMatch(/can only be one/)
})
test('using the custom end point (200 OK)', async () => {
const res = await get(`/__tracking__?${DOMAIN_QUERY_PARAM}=Acme.example.com`)
expect(res.statusCode).toBe(200)
expect(res.body).toMatch(/OK/)
})
test('using the custom end point with no value', async () => {
const res = await get(`/__tracking__?${DOMAIN_QUERY_PARAM}=`)
expect(res.statusCode).toBe(200)
expect(res.body).toMatch(/OK/)
})
test('using the custom end point (400 Bad request)', async () => {
const res = await get('/__tracking__')
expect(res.statusCode).toBe(400)
expect(res.body).toMatch(/Lacking query string/)
})
})