diff --git a/content/actions/deployment/managing-your-deployments/viewing-deployment-history.md b/content/actions/deployment/managing-your-deployments/viewing-deployment-history.md
index 5e74c424f5..e703c4a8e3 100644
--- a/content/actions/deployment/managing-your-deployments/viewing-deployment-history.md
+++ b/content/actions/deployment/managing-your-deployments/viewing-deployment-history.md
@@ -35,7 +35,8 @@ By default, the deployments page shows currently active deployments from select
1. In the right-hand sidebar of the home page of your repository, click **Deployments**.
1. Once you are on the "Deployments" page, you can view the following information about your deployment history.
- - **To view recent deployments for a specific environment**, in the "Environments" section of the left sidebar, click an environment. {% ifversion deployment-dashboard-filter %}To pin an environment to the top of the deployment history list, click {% octicon "pin" aria-label="Pin environment" %} to the right of the environment.{% endif %}
+ - **To view recent deployments for a specific environment**, in the "Environments" section of the left sidebar, click an environment.{% ifversion deployment-dashboard-filter %}
+ - **To pin an environment to the top of the deployment history list**, repository administrators can click {% octicon "pin" aria-label="Pin environment" %} to the right of the environment. You can pin up to ten environments.{% endif %}
- **To view the commit that triggered a deployment**, in the deployment history list, click the commit message for the deployment you want to view.
>[!NOTE]Deployments from commits that originate from a fork outside of the repository will not show links to the source pull request and branch related to each deployment. For more information about forks, see "[AUTOTITLE](/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks)."
- **To view the URL for a deployment**, to the right of the commit message in the deployment history list, click {% octicon "link-external" aria-label="Navigate to deployment URL" %}.
diff --git a/src/content-render/unified/processor.js b/src/content-render/unified/processor.js
index 5981b03fc9..19dec73f30 100644
--- a/src/content-render/unified/processor.js
+++ b/src/content-render/unified/processor.js
@@ -27,6 +27,7 @@ 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'
export function createProcessor(context) {
return (
@@ -44,6 +45,7 @@ export function createProcessor(context) {
.use(headingLinks)
.use(codeHeader)
.use(annotate)
+ .use(replaceDomain)
.use(highlight, {
languages: { ...common, graphql, dockerfile, http, groovy, erb, powershell },
subset: false,
diff --git a/src/content-render/unified/replace-domain.js b/src/content-render/unified/replace-domain.js
new file mode 100644
index 0000000000..114a574f61
--- /dev/null
+++ b/src/content-render/unified/replace-domain.js
@@ -0,0 +1,43 @@
+/**
+ * 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
+ let found = false
+ 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
+ }
+ found = true
+ }
+ }
+
+ if (!found && process.env.NODE_ENV === 'development') {
+ console.warn("The code snippet doesn't contain a valid replacement", { code })
+ }
+ })
+ }
+}
diff --git a/src/fixtures/fixtures/content/get-started/markdown/index.md b/src/fixtures/fixtures/content/get-started/markdown/index.md
index e2ebf40267..8b0c49e7dd 100644
--- a/src/fixtures/fixtures/content/get-started/markdown/index.md
+++ b/src/fixtures/fixtures/content/get-started/markdown/index.md
@@ -10,4 +10,5 @@ children:
- /permissions
- /code-annotations
- /alerts
+ - /replace-domain
---
diff --git a/src/fixtures/fixtures/content/get-started/markdown/replace-domain.md b/src/fixtures/fixtures/content/get-started/markdown/replace-domain.md
new file mode 100644
index 0000000000..40284fe613
--- /dev/null
+++ b/src/fixtures/fixtures/content/get-started/markdown/replace-domain.md
@@ -0,0 +1,40 @@
+---
+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")
+```
diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts
index 3200b19933..41e39632e2 100644
--- a/src/fixtures/tests/playwright-rendering.spec.ts
+++ b/src/fixtures/tests/playwright-rendering.spec.ts
@@ -593,3 +593,35 @@ 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")')
+ })
+})
diff --git a/src/frame/components/article/ArticlePage.tsx b/src/frame/components/article/ArticlePage.tsx
index 8d51d1f9f3..5155ade59e 100644
--- a/src/frame/components/article/ArticlePage.tsx
+++ b/src/frame/components/article/ArticlePage.tsx
@@ -21,6 +21,7 @@ import { Breadcrumbs } from 'src/frame/components/page-header/Breadcrumbs'
import { Link } from 'src/frame/components/Link'
import { useTranslation } from 'src/languages/components/useTranslation'
import { LinkPreviewPopover } from 'src/links/components/LinkPreviewPopover'
+import { ReplaceDomain } from 'src/links/components/replace-domain'
const ClientSideRefresh = dynamic(() => import('src/frame/components/ClientSideRefresh'), {
ssr: false,
@@ -103,6 +104,7 @@ export const ArticlePage = () => {
{isDev && }
{router.pathname.includes('/rest/') && }
+
{currentLayout === 'inline' ? (
<>
('pre code[data-replacedomain]').forEach((codeBlock) => {
+ const replaceDomain = codeBlock.dataset.replacedomain
+ if (!replaceDomain) return
+ const replaceDomains = replaceDomain.split(/\s/)
+ const spans = codeBlock.querySelectorAll('span[class*="-string"]')
+ if (spans.length) {
+ spans.forEach((span) => {
+ replaceInTextContent(span, replaceDomains, domain)
+ })
+ } else {
+ replaceInTextContent(codeBlock, replaceDomains, domain)
+ }
+ })
+}
+
+function replaceInTextContent(
+ codeBlock: HTMLElement,
+ replaceDomains: string[],
+ domain: string | null,
+) {
+ if (!codeBlock.textContent) return
+ for (const replaceDomain of replaceDomains) {
+ if (replaceDomain in regexes) {
+ // If the domain is falsy, it means we're reverting the replacement.
+ // This happens when you used to be on a version where we want to
+ // activate this functionality. Then, when you switch to a version
+ // where you don't want it, we need to revert the replacement to
+ // to what it was before we did the first replacement.
+ if (domain) {
+ const match = codeBlock.textContent.match(regexes[replaceDomain as keyof typeof regexes])
+ for (const matched of match || []) {
+ codeBlock.dataset.replacedomainOriginal = matched
+ codeBlock.dataset.replacedomainReplace = domain
+ }
+ codeBlock.textContent = codeBlock.textContent.replace(
+ regexes[replaceDomain as keyof typeof regexes],
+ domain,
+ )
+ } else {
+ if (codeBlock.dataset.replacedomainOriginal && codeBlock.dataset.replacedomainReplace) {
+ // Reverse it
+ codeBlock.textContent = codeBlock.textContent.replace(
+ codeBlock.dataset.replacedomainReplace,
+ codeBlock.dataset.replacedomainOriginal,
+ )
+ }
+ }
+ }
+ }
+}
+
+export function ReplaceDomain() {
+ const { asPath } = useRouter()
+ const { currentVersion } = useVersion()
+
+ const bother = REPLACEDOMAIN_VERSION_PREFIXES.some((prefix) => currentVersion.startsWith(prefix))
+
+ useEffect(() => {
+ const cookieValue = Cookies.get(COOKIE_KEY)
+ if (cookieValue) {
+ if (bother) {
+ replaceDomains(cookieValue.split(',')[0])
+ } else {
+ replaceDomains(null)
+ }
+ }
+ }, [asPath, bother])
+ return null
+}