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