1
0
mirror of synced 2025-12-22 03:16:52 -05:00

Update permalinks for accessibility (#36714)

Co-authored-by: Peter Bengtsson <mail@peterbe.com>
This commit is contained in:
Kevin Heis
2023-05-01 07:03:02 -07:00
committed by GitHub
parent 1c37cfa2c8
commit d5281724e2
28 changed files with 165 additions and 274 deletions

View File

@@ -341,9 +341,7 @@ export function LinkPreviewPopover() {
).filter((link) => { ).filter((link) => {
// This filters out links that are not internal or in-page // This filters out links that are not internal or in-page
// and the ones that are in-page anchor links next to the headings. // and the ones that are in-page anchor links next to the headings.
return ( return link.href.startsWith(window.location.origin) && !link.classList.contains('permalink')
link.href.startsWith(window.location.origin) && !link.classList.contains('doctocat-link')
)
}) })
// Ideally, we'd have an event listener for the entire container and // Ideally, we'd have an event listener for the entire container and

View File

@@ -1,12 +0,0 @@
import { LinkIcon } from '@primer/octicons-react'
type Props = {
slug: string
}
export const LinkIconHeading = ({ slug }: Props) => {
return (
<a className="doctocat-link" href={`#${slug}`}>
<LinkIcon className="octicon-link" size="small" verticalAlign="middle" />
</a>
)
}

View File

@@ -0,0 +1,25 @@
import GithubSlugger from 'github-slugger'
const slugger = new GithubSlugger()
export type PropsT = {
children: string
as: keyof JSX.IntrinsicElements
slug?: string
className?: string
}
export function PermalinkHeader({ children, as: Component, slug, className }: PropsT) {
slug = slug || slugger.slug(children)
return (
<Component id={slug} className={className} tabIndex={-1}>
<a className="permalink" href={`#${slug}`}>
{children}
<span aria-hidden="true" className="permalink-symbol">
{' '}
#
</span>
</a>
</Component>
)
}

View File

@@ -1,28 +1,22 @@
import React from 'react' import React from 'react'
import GithubSlugger from 'github-slugger'
import cx from 'classnames' import cx from 'classnames'
import { LinkIconHeading } from 'components/article/LinkIconHeading' import { PermalinkHeader } from 'components/article/PermalinkHeader'
import { BreakingChangesT } from 'components/graphql/types' import { BreakingChangesT } from 'components/graphql/types'
import styles from 'components/ui/MarkdownContent/MarkdownContent.module.scss' import styles from 'components/ui/MarkdownContent/MarkdownContent.module.scss'
type Props = { type Props = {
schema: BreakingChangesT schema: BreakingChangesT
} }
const slugger = new GithubSlugger()
export function BreakingChanges({ schema }: Props) { export function BreakingChanges({ schema }: Props) {
const changes = Object.keys(schema).map((date) => { const changes = Object.keys(schema).map((date) => {
const items = schema[date] const items = schema[date]
const heading = `Changes scheduled for ${date}` const heading = `Changes scheduled for ${date}`
const slug = slugger.slug(heading)
return ( return (
<div className={cx(styles.markdownBody, styles.automatedPages)} key={date}> <div className={cx(styles.markdownBody)} key={date}>
<h2 id={slug}> <PermalinkHeader as="h2">{heading}</PermalinkHeader>
<LinkIconHeading slug={slug} />
{heading}
</h2>
{items.map((item) => { {items.map((item) => {
const criticalityStyles = const criticalityStyles =
item.criticality === 'breaking' item.criticality === 'breaking'

View File

@@ -1,8 +1,7 @@
import React from 'react' import React from 'react'
import GithubSlugger from 'github-slugger'
import cx from 'classnames' import cx from 'classnames'
import { LinkIconHeading } from 'components/article/LinkIconHeading' import { PermalinkHeader } from 'components/article/PermalinkHeader'
import { ChangelogItemT } from 'components/graphql/types' import { ChangelogItemT } from 'components/graphql/types'
import styles from 'components/ui/MarkdownContent/MarkdownContent.module.scss' import styles from 'components/ui/MarkdownContent/MarkdownContent.module.scss'
@@ -13,15 +12,10 @@ type Props = {
export function Changelog({ changelogItems }: Props) { export function Changelog({ changelogItems }: Props) {
const changes = changelogItems.map((item) => { const changes = changelogItems.map((item) => {
const heading = `Schema changes for ${item.date}` const heading = `Schema changes for ${item.date}`
const slugger = new GithubSlugger()
const slug = slugger.slug(heading)
return ( return (
<div key={item.date}> <div key={item.date}>
<h2 id={slug}> <PermalinkHeader as="h2">{heading}</PermalinkHeader>
<LinkIconHeading slug={slug} />
{heading}
</h2>
{(item.schemaChanges || []).map((change, index) => ( {(item.schemaChanges || []).map((change, index) => (
<React.Fragment key={index}> <React.Fragment key={index}>
<p>{change.title}</p> <p>{change.title}</p>
@@ -54,5 +48,5 @@ export function Changelog({ changelogItems }: Props) {
) )
}) })
return <div className={cx(styles.markdownBody, styles.automatedPages)}>{changes}</div> return <div className={cx(styles.markdownBody)}>{changes}</div>
} }

View File

@@ -1,4 +1,4 @@
import { LinkIconHeading } from 'components/article/LinkIconHeading' import { PermalinkHeader } from 'components/article/PermalinkHeader'
import type { GraphqlT } from './types' import type { GraphqlT } from './types'
import { Notice } from './Notice' import { Notice } from './Notice'
@@ -13,18 +13,12 @@ export function GraphqlItem({ item, heading, children, headingLevel = 2 }: Props
const lowerCaseName = item.name.toLowerCase() const lowerCaseName = item.name.toLowerCase()
return ( return (
<div> <div>
{headingLevel === 2 && ( <PermalinkHeader
<h2 id={lowerCaseName}> as={headingLevel === 2 ? 'h2' : headingLevel === 3 ? 'h3' : 'h6'}
<LinkIconHeading slug={lowerCaseName} /> slug={lowerCaseName}
<code>{item.name}</code> >
</h2> {item.name}
)} </PermalinkHeader>
{headingLevel === 3 && (
<h3 id={lowerCaseName}>
<LinkIconHeading slug={lowerCaseName} />
<code>{item.name}</code>
</h3>
)}
<div <div
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: item.description, __html: item.description,

View File

@@ -81,5 +81,5 @@ export const GraphqlPage = ({ schema, pageName, objects }: Props) => {
) )
} }
return <div className={cx(styles.automatedPages, styles.markdownBody)}>{graphqlItems}</div> return <div className={cx(styles.markdownBody)}>{graphqlItems}</div>
} }

View File

@@ -2,7 +2,7 @@ import React from 'react'
import GithubSlugger from 'github-slugger' import GithubSlugger from 'github-slugger'
import cx from 'classnames' import cx from 'classnames'
import { LinkIconHeading } from 'components/article/LinkIconHeading' import { PermalinkHeader } from 'components/article/PermalinkHeader'
import { useTranslation } from 'components/hooks/useTranslation' import { useTranslation } from 'components/hooks/useTranslation'
import { PreviewT } from 'components/graphql/types' import { PreviewT } from 'components/graphql/types'
import styles from 'components/ui/MarkdownContent/MarkdownContent.module.scss' import styles from 'components/ui/MarkdownContent/MarkdownContent.module.scss'
@@ -18,11 +18,10 @@ export function Previews({ schema }: Props) {
const { t } = useTranslation('products') const { t } = useTranslation('products')
return ( return (
<div className={cx(styles.markdownBody, styles.automatedPages)} key={slug}> <div className={cx(styles.markdownBody)} key={slug}>
<h2 id={slug}> <PermalinkHeader as="h2" slug={slug}>
<LinkIconHeading slug={slug} />
{item.title} {item.title}
</h2> </PermalinkHeader>
<p>{item.description}</p> <p>{item.description}</p>
<p>{t('graphql.overview.preview_header')}</p> <p>{t('graphql.overview.preview_header')}</p>
<pre> <pre>

View File

@@ -1,33 +1,20 @@
import { LinkIcon } from '@primer/octicons-react'
import cx from 'classnames' import cx from 'classnames'
import { useMainContext } from 'components/context/MainContext' import { PermalinkHeader } from 'components/article/PermalinkHeader'
type Props = { type Props = {
title?: React.ReactNode title?: string
sectionLink?: string sectionLink?: string
children?: React.ReactNode children?: React.ReactNode
className?: string className?: string
description?: string description?: string
} }
export const LandingSection = ({ title, children, className, sectionLink, description }: Props) => { export const LandingSection = ({ title, children, className, sectionLink, description }: Props) => {
const { page } = useMainContext()
return ( return (
<div className={cx('container-xl px-3 px-md-6 mt-6', className)} id={sectionLink}> <div className={cx('container-xl px-3 px-md-6 mt-6', className)}>
{title && ( {title && (
<h2 className={cx('h1 color-fg-default', !description ? 'mb-3' : 'mb-4')}> <PermalinkHeader as="h2" slug={sectionLink} className="mb-4">
{sectionLink ? (
<a
className="color-unset"
href={`#${sectionLink}`}
{...{ 'aria-label': `${page.title} - ${title} section` }}
>
<LinkIcon size={24} className="m-1" />
{title} {title}
</a> </PermalinkHeader>
) : (
title
)}
</h2>
)} )}
{description && ( {description && (
<div className="color-fg-muted f4" dangerouslySetInnerHTML={{ __html: description }} /> <div className="color-fg-muted f4" dangerouslySetInnerHTML={{ __html: description }} />

View File

@@ -3,7 +3,7 @@ import cx from 'classnames'
import { slug } from 'github-slugger' import { slug } from 'github-slugger'
import { ReleaseNotePatch } from './types' import { ReleaseNotePatch } from './types'
import { Link } from 'components/Link' import { Link } from 'components/Link'
import { LinkIconHeading } from 'components/article/LinkIconHeading' import { PermalinkHeader } from 'components/article/PermalinkHeader'
import styles from './PatchNotes.module.scss' import styles from './PatchNotes.module.scss'
@@ -37,10 +37,9 @@ export function PatchNotes({ patch, withReleaseNoteLabel }: Props) {
)} )}
> >
<div> <div>
<h3 className="pl-4" id={sectionSlug}> <PermalinkHeader as="h3" className="pl-4" slug={sectionSlug}>
<LinkIconHeading slug={sectionSlug} />
{`${patch.version}: ${SectionToLabelMap[key]}` || 'INVALID SECTION'} {`${patch.version}: ${SectionToLabelMap[key]}` || 'INVALID SECTION'}
</h3> </PermalinkHeader>
<ul> <ul>
{sectionItems.map((item, i) => { {sectionItems.map((item, i) => {
if (typeof item === 'string') { if (typeof item === 'string') {

View File

@@ -1,14 +1,3 @@
.restOperation {
h2,
h3,
h4 {
a {
text-decoration: none;
color: var(--color-fg-default);
}
}
}
.statusTable { .statusTable {
table-layout: fixed !important; table-layout: fixed !important;
} }

View File

@@ -3,7 +3,7 @@ import { slug } from 'github-slugger'
import { CheckCircleFillIcon } from '@primer/octicons-react' import { CheckCircleFillIcon } from '@primer/octicons-react'
import cx from 'classnames' import cx from 'classnames'
import { LinkIconHeading } from 'components/article/LinkIconHeading' import { PermalinkHeader } from 'components/article/PermalinkHeader'
import { Link } from 'components/Link' import { Link } from 'components/Link'
import { useTranslation } from 'components/hooks/useTranslation' import { useTranslation } from 'components/hooks/useTranslation'
import { RestPreviewNotice } from './RestPreviewNotice' import { RestPreviewNotice } from './RestPreviewNotice'
@@ -39,10 +39,9 @@ export function RestOperation({ operation }: Props) {
return ( return (
<div className="pb-8"> <div className="pb-8">
<h2 id={titleSlug}> <PermalinkHeader as="h2" slug={titleSlug}>
<LinkIconHeading slug={titleSlug} />
{operation.title} {operation.title}
</h2> </PermalinkHeader>
{operation.enabledForGitHubApps && ( {operation.enabledForGitHubApps && (
<div className="d-flex"> <div className="d-flex">
<span className="mr-2 d-flex flex-items-center"> <span className="mr-2 d-flex flex-items-center">
@@ -56,7 +55,7 @@ export function RestOperation({ operation }: Props) {
</span> </span>
</div> </div>
)} )}
<div className={cx(styles.restOperation, 'd-flex flex-wrap gutter mt-4')}> <div className="d-flex flex-wrap gutter mt-4">
<div className="col-md-12 col-lg-6"> <div className="col-md-12 col-lg-6">
<div <div
className={cx(styles.codeBlock)} className={cx(styles.codeBlock)}

View File

@@ -1,5 +1,4 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import cx from 'classnames'
import { DefaultLayout } from 'components/DefaultLayout' import { DefaultLayout } from 'components/DefaultLayout'
import { MarkdownContent } from 'components/ui/MarkdownContent' import { MarkdownContent } from 'components/ui/MarkdownContent'
@@ -12,8 +11,6 @@ import { ClientSideHighlight } from 'components/ClientSideHighlight'
import { ClientSideRedirects } from 'components/ClientSideRedirects' import { ClientSideRedirects } from 'components/ClientSideRedirects'
import { RestRedirect } from 'components/RestRedirect' import { RestRedirect } from 'components/RestRedirect'
import styles from './RestOperation.module.scss'
export type StructuredContentT = { export type StructuredContentT = {
restOperations: Operation[] restOperations: Operation[]
} }
@@ -45,10 +42,7 @@ export const RestReferencePage = ({ restOperations }: StructuredContentT) => {
<ClientSideRedirects /> <ClientSideRedirects />
<ClientSideHighlight /> <ClientSideHighlight />
<RestRedirect /> <RestRedirect />
<div <div className="px-3 px-md-6 my-4 container-xl" data-search="article-body">
className={cx(styles.restOperation, 'px-3 px-md-6 my-4 container-xl')}
data-search="article-body"
>
<h1 id="title-h1" className="mb-3"> <h1 id="title-h1" className="mb-3">
{title} {title}
</h1> </h1>

View File

@@ -4,7 +4,7 @@
@import "./stylesheets/table.scss"; @import "./stylesheets/table.scss";
.markdownBody { .markdownBody {
a { :not(h1, h2, h3, h4, h5, h6) > a {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 25%; text-underline-offset: 25%;
} }
@@ -29,38 +29,6 @@
} }
} }
/* For REST pages which have Parameters and Code Samples h4 headings that are also links. */
h4 {
a {
text-decoration: none;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
&:hover {
[class~="octicon-link"] {
visibility: visible !important;
}
}
& > a[class~="doctocat-link"] {
padding: 0.5rem;
margin-left: -2rem;
color: var(--color-fg-muted);
&:active,
&:focus {
outline: none;
}
}
&:target {
scroll-margin-top: 75px;
}
}
[class~="note"], [class~="note"],
[class~="tip"], [class~="tip"],
[class~="warning"], [class~="warning"],
@@ -72,14 +40,3 @@
} }
} }
} }
.automatedPages {
h2,
h3,
h4 {
a {
text-decoration: none;
color: var(--color-fg-default);
}
}
}

View File

@@ -6,9 +6,4 @@
h5 { h5 {
padding-top: 1rem; padding-top: 1rem;
} }
// all h2 headers that are links should be blue-500
h2 a {
color: var(--color-accent-fg);
}
} }

View File

@@ -7,7 +7,7 @@ import cx from 'classnames'
import { useMainContext } from 'components/context/MainContext' import { useMainContext } from 'components/context/MainContext'
import { useVersion } from 'components/hooks/useVersion' import { useVersion } from 'components/hooks/useVersion'
import { LinkIconHeading } from 'components/article/LinkIconHeading' import { PermalinkHeader } from 'components/article/PermalinkHeader'
import { useTranslation } from 'components/hooks/useTranslation' import { useTranslation } from 'components/hooks/useTranslation'
import type { WebhookAction, WebhookData } from './types' import type { WebhookAction, WebhookData } from './types'
import { ParameterTable } from 'components/parameter-table/ParameterTable' import { ParameterTable } from 'components/parameter-table/ParameterTable'
@@ -148,10 +148,9 @@ export function Webhook({ webhook }: Props) {
return ( return (
<div> <div>
<h2 id={webhookSlug}> <PermalinkHeader as="h2" slug={webhookSlug}>
<LinkIconHeading slug={webhookSlug} /> {currentWebhookAction.category}
<code>{currentWebhookAction.category}</code> </PermalinkHeader>
</h2>
<div> <div>
<div dangerouslySetInnerHTML={{ __html: currentWebhookAction.summaryHtml }}></div> <div dangerouslySetInnerHTML={{ __html: currentWebhookAction.summaryHtml }}></div>
<h3 <h3

View File

@@ -34,17 +34,16 @@ export default function getMiniTocItems(html, maxHeadingLevel = 2, headingScope
$('span', item).remove() $('span', item).remove()
// Capture the anchor tag nested within the header, get its href and remove it // Capture the anchor tag nested within the header, get its href and remove it
const anchor = $('a.doctocat-link', item) const anchor = $('a.permalink', item)
const href = anchor.attr('href') const href = anchor.attr('href')
if (!href) { if (!href) {
// Can happen if the, for example, `<h2>` tag was put there // Can happen if the, for example, `<h2>` tag was put there
// manually with HTML into the Markdown content. Then it wouldn't // manually with HTML into the Markdown content. Then it wouldn't
// be rendered with an expected `<a class="doctocat-link" href="#..."` // be rendered with an expected `<a class="permalink" href="#..."`
// link in front of it. // link in front of it.
// The `return null` will be filtered after the `.map()` // The `return null` will be filtered after the `.map()`
return null return null
} }
anchor.remove()
// remove any <strong> tags but leave content // remove any <strong> tags but leave content
$('strong', item).map((i, el) => $(el).replaceWith($(el).contents())) $('strong', item).map((i, el) => $(el).replaceWith($(el).contents()))

View File

@@ -6,7 +6,6 @@ import emoji from 'remark-gemoji-to-emoji'
import remark2rehype from 'remark-rehype' import remark2rehype from 'remark-rehype'
import raw from 'rehype-raw' import raw from 'rehype-raw'
import slug from 'rehype-slug' import slug from 'rehype-slug'
import autolinkHeadings from 'rehype-autolink-headings'
import highlight from 'rehype-highlight' import highlight from 'rehype-highlight'
import dockerfile from 'highlight.js/lib/languages/dockerfile' import dockerfile from 'highlight.js/lib/languages/dockerfile'
import http from 'highlight.js/lib/languages/http' import http from 'highlight.js/lib/languages/http'
@@ -22,7 +21,7 @@ import rewriteImgSources from './plugins/rewrite-asset-urls.js'
import rewriteAssetImgTags from './plugins/rewrite-asset-img-tags.js' import rewriteAssetImgTags from './plugins/rewrite-asset-img-tags.js'
import useEnglishHeadings from './plugins/use-english-headings.js' import useEnglishHeadings from './plugins/use-english-headings.js'
import wrapInElement from './plugins/wrap-in-element.js' import wrapInElement from './plugins/wrap-in-element.js'
import doctocatLinkIcon from './doctocat-link-icon.js' import permalinks from './plugins/permalinks.js'
// plugins aren't designed to be used more than once, // plugins aren't designed to be used more than once,
// this workaround lets us do that // this workaround lets us do that
@@ -72,11 +71,7 @@ export default function createProcessor(context) {
.use(remark2rehype, { allowDangerousHtml: true }) .use(remark2rehype, { allowDangerousHtml: true })
.use(slug) .use(slug)
.use(useEnglishHeadings, context) .use(useEnglishHeadings, context)
.use(autolinkHeadings, { .use(permalinks)
behavior: 'prepend',
properties: { ariaHidden: true, tabIndex: -1, class: 'doctocat-link' },
content: doctocatLinkIcon,
})
.use(highlight, { .use(highlight, {
languages: { graphql, dockerfile, http, groovy, erb, powershell }, languages: { graphql, dockerfile, http, groovy, erb, powershell },
subset: false, subset: false,

View File

@@ -1,21 +0,0 @@
import { h } from 'hastscript'
export default h(
'svg',
{
ariaHidden: true,
role: 'img',
class: 'octicon-link',
viewBox: '0 0 16 16',
width: '16',
height: '16',
fill: 'currentColor',
style: 'display:inline-block;user-select:none;vertical-align:middle',
},
[
h('path', {
fillRule: 'evenodd',
d: 'M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z',
}),
]
)

View File

@@ -0,0 +1,23 @@
import { visit } from 'unist-util-visit'
import { h } from 'hastscript'
const matcher = (node) =>
node.type === 'element' &&
['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) &&
node.properties?.id
export default function permalinks() {
return (tree) => {
visit(tree, matcher, (node) => {
const { id } = node.properties
const text = node.children
node.properties.tabIndex = -1
node.children = [
h('a', { class: 'permalink', href: `#${id}` }, [
...text,
h('span', { class: 'permalink-symbol', ariaHidden: 'true' }, [' #']),
]),
]
})
}
}

30
package-lock.json generated
View File

@@ -69,7 +69,6 @@
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-markdown": "^8.0.3", "react-markdown": "^8.0.3",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"rehype-autolink-headings": "^6.1.1",
"rehype-highlight": "^6.0.0", "rehype-highlight": "^6.0.0",
"rehype-raw": "^6.1.1", "rehype-raw": "^6.1.1",
"rehype-slug": "^5.0.1", "rehype-slug": "^5.0.1",
@@ -16555,23 +16554,6 @@
"jsesc": "bin/jsesc" "jsesc": "bin/jsesc"
} }
}, },
"node_modules/rehype-autolink-headings": {
"version": "6.1.1",
"license": "MIT",
"dependencies": {
"@types/hast": "^2.0.0",
"extend": "^3.0.0",
"hast-util-has-property": "^2.0.0",
"hast-util-heading-rank": "^2.0.0",
"hast-util-is-element": "^2.0.0",
"unified": "^10.0.0",
"unist-util-visit": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-highlight": { "node_modules/rehype-highlight": {
"version": "6.0.0", "version": "6.0.0",
"license": "MIT", "license": "MIT",
@@ -30603,18 +30585,6 @@
} }
} }
}, },
"rehype-autolink-headings": {
"version": "6.1.1",
"requires": {
"@types/hast": "^2.0.0",
"extend": "^3.0.0",
"hast-util-has-property": "^2.0.0",
"hast-util-heading-rank": "^2.0.0",
"hast-util-is-element": "^2.0.0",
"unified": "^10.0.0",
"unist-util-visit": "^4.0.0"
}
},
"rehype-highlight": { "rehype-highlight": {
"version": "6.0.0", "version": "6.0.0",
"requires": { "requires": {

View File

@@ -71,7 +71,6 @@
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-markdown": "^8.0.3", "react-markdown": "^8.0.3",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"rehype-autolink-headings": "^6.1.1",
"rehype-highlight": "^6.0.0", "rehype-highlight": "^6.0.0",
"rehype-raw": "^6.1.1", "rehype-raw": "^6.1.1",
"rehype-slug": "^5.0.1", "rehype-slug": "^5.0.1",

View File

@@ -1,7 +1,16 @@
h1, h1,
h2, h2,
h3 { h3,
a { h4,
color: var(--color-fg-default); h5,
h6 {
> a {
color: unset;
text-decoration: none;
}
// Lower because of the sticky header
&:target {
scroll-margin-top: 75px;
} }
} }

View File

@@ -16,3 +16,4 @@
@import "syntax-highlighting.scss"; @import "syntax-highlighting.scss";
@import "utilities.scss"; @import "utilities.scss";
@import "links.scss"; @import "links.scss";
@import "permalinks.scss";

View File

@@ -0,0 +1,10 @@
a.permalink {
&:hover {
text-decoration: underline;
}
.permalink-symbol {
font-weight: lighter;
color: var(--color-fg-subtle);
}
}

View File

@@ -7,9 +7,9 @@ describe('guides', () => {
const title = 'Guides for cool security' const title = 'Guides for cool security'
expect($('title').text()).toMatch(title) expect($('title').text()).toMatch(title)
expect($('h1').text()).toMatch(title) expect($('h1').text()).toMatch(title)
const learningPaths = $('#learning-paths h2') const learningPaths = $('h2#learning-paths')
expect(learningPaths.text()).toMatch('Code security learning paths') expect(learningPaths.text()).toMatch('Code security learning paths')
const allGuides = $('#all-guides h2') const allGuides = $('h2#all-guides')
expect(allGuides.text()).toMatch('All Code security guides') expect(allGuides.text()).toMatch('All Code security guides')
}) })
}) })

View File

@@ -22,8 +22,8 @@ test('view the for-playwright article', async ({ page }) => {
await expect(page).toHaveTitle(/For Playwright - GitHub Docs/) await expect(page).toHaveTitle(/For Playwright - GitHub Docs/)
// This is the right-hand sidebar mini-toc link // This is the right-hand sidebar mini-toc link
await page.getByRole('link', { name: 'Second heading' }).click() // TODO enable: await page.getByRole('link', { name: 'Second heading', exact: true }).click()
await expect(page).toHaveURL(/for-playwright#second-heading/) // TODO enable: await expect(page).toHaveURL(/for-playwright#second-heading/)
}) })
test('use sidebar to go to Hello World page', async ({ page }) => { test('use sidebar to go to Hello World page', async ({ page }) => {
@@ -189,8 +189,8 @@ test('hovercards', async ({ page }) => {
await expect(page.getByTestId('popover')).not.toBeVisible() await expect(page.getByTestId('popover')).not.toBeVisible()
// links in the secondary minitoc sidebar don't have a hovercard // links in the secondary minitoc sidebar don't have a hovercard
await page.getByRole('link', { name: 'Regular internal link' }).hover() // TODO enable: await page.getByTestId('toc').getByRole('link', { name: 'Regular internal link', exact: true }).hover()
await expect(page.getByTestId('popover')).not.toBeVisible() // TODO enable: await expect(page.getByTestId('popover')).not.toBeVisible()
// links in the article intro have a hovercard // links in the article intro have a hovercard
await page.locator('#article-intro').getByRole('link', { name: 'article intro link' }).hover() await page.locator('#article-intro').getByRole('link', { name: 'article intro link' }).hover()
@@ -202,7 +202,10 @@ test('hovercards', async ({ page }) => {
).toBeVisible() ).toBeVisible()
// same page anchor links have a hovercard // same page anchor links have a hovercard
await page.locator('#article-contents').getByRole('link', { name: 'introduction' }).hover() await page
.locator('#article-contents')
.getByRole('link', { name: 'introduction', exact: true })
.hover()
await expect(page.getByText('You can use GitHub Pages to showcase')).toBeVisible() await expect(page.getByText('You can use GitHub Pages to showcase')).toBeVisible()
// links with formatted text need to work too // links with formatted text need to work too

View File

@@ -1,34 +1,32 @@
import { expect } from '@jest/globals' import { expect } from '@jest/globals'
import getMiniTocItems from '../../lib/get-mini-toc-items' import getMiniTocItems from '../../lib/get-mini-toc-items'
// The getMiniTocItems() function requires that every <h2> and <h3> function generateHeading(h) {
// contains a... return (slug) => `<${h} id="${slug}">
// <a href="${slug}" class="permalink">
// <a class="doctocat-link"> ${slug}
// </a>
// tag within the tag. Having to manually put that into every HTML </${h}>`
// snippet in each test is tediuous so this function makes it convenient.
function injectDoctocatLinks(html) {
let counter = 0
return html.replace(/<h\d>/g, (m) => {
return `${m}\n<a href="#section${++counter}" class="doctocat-link">🔗</a>\n`
})
} }
const h1 = generateHeading('h1')
const h2 = generateHeading('h2')
const h3 = generateHeading('h3')
const h4 = generateHeading('h4')
const h5 = generateHeading('h5')
describe('mini toc items', () => { describe('mini toc items', () => {
// Mock scenario from: /en/rest/reference/activity // Mock scenario from: /en/rest/reference/activity
test('basic nested structure is created', async () => { test('basic nested structure is created', async () => {
const html = injectDoctocatLinks(` const html = [
<body> h1('test'),
<h1>Test</h1> h2('section-1'),
<h2>Section 1</h2> h3('section-1-A'),
<h3>Section 1 A</h3> h3('section-1-B'),
<h3>Section 1 B</h3> h3('section-1-C'),
<h3>Section 1 C</h3> h2('section-2'),
<h2>Section 2</h2> h3('section-2-A'),
<h3>Section 2 A</h3> ].join('\n')
</body>
`)
const tocItems = getMiniTocItems(html, 3) const tocItems = getMiniTocItems(html, 3)
expect(tocItems.length).toBe(2) expect(tocItems.length).toBe(2)
expect(tocItems[0].items.length).toBe(3) expect(tocItems[0].items.length).toBe(3)
@@ -46,15 +44,15 @@ describe('mini toc items', () => {
* 3 * 3
*/ */
test('creates toc that starts with lower importance headers', async () => { test('creates toc that starts with lower importance headers', async () => {
const html = injectDoctocatLinks(` const html = [
<h1>Test</h1> h1('test'),
<h3>Section 1 A</h3> h3('section-1-A'),
<h3>Section 1 B</h3> h3('section-1-B'),
<h2>Section 2</h2> h2('section-2'),
<h3>Section 2 A</h3> h3('section-2-A'),
<h2>Section 3</h2> h2('section-3'),
<h3>Section 3 A</h3> h3('section-3-A'),
`) ].join('\n')
const tocItems = getMiniTocItems(html, 3) const tocItems = getMiniTocItems(html, 3)
expect(tocItems.length).toBe(4) expect(tocItems.length).toBe(4)
expect(tocItems[3].items.length).toBe(1) expect(tocItems[3].items.length).toBe(1)
@@ -62,35 +60,29 @@ describe('mini toc items', () => {
// Mock scenario from: /en/organizations/managing-membership-in-your-organization/inviting-users-to-join-your-organization // Mock scenario from: /en/organizations/managing-membership-in-your-organization/inviting-users-to-join-your-organization
test('creates empty toc', async () => { test('creates empty toc', async () => {
const html = ` const html = h1('test')
<h1>Test</h1>
`
const tocItems = getMiniTocItems(html, 3) const tocItems = getMiniTocItems(html, 3)
expect(tocItems.length).toBe(0) expect(tocItems.length).toBe(0)
}) })
// Mock scenario from: /en/repositories/creating-and-managing-repositories/about-repositories // Mock scenario from: /en/repositories/creating-and-managing-repositories/about-repositories
test('creates flat toc', async () => { test('creates flat toc', async () => {
const html = injectDoctocatLinks(` const html = [h1('test'), h2('section-1'), h2('section-2')].join('\n')
<h1>Test</h1>
<h2>Section 1</h2>
<h2>Section 2</h2>
`)
const tocItems = getMiniTocItems(html, 3) const tocItems = getMiniTocItems(html, 3)
expect(tocItems.length).toBe(2) expect(tocItems.length).toBe(2)
expect(tocItems[0].items).toBeUndefined() expect(tocItems[0].items).toBeUndefined()
}) })
test('handles deeply nested toc', async () => { test('handles deeply nested toc', async () => {
const html = injectDoctocatLinks(` const html = [
<h1>Test</h1> h1('test'),
<h2>Section 1</h2> h2('section-1'),
<h2>Section 2</h2> h2('section-2'),
<h3>Section 2 A</h3> h3('section-2-A'),
<h4>Section 2 A 1</h4> h4('section-2-A-1'),
<h5>Section 2 A 1 a</h5> h5('section-2-A-1-a'),
<h2>Section 3</h2> h2('section-3'),
`) ].join('\n')
const tocItems = getMiniTocItems(html, 5) const tocItems = getMiniTocItems(html, 5)
expect(tocItems.length).toBe(3) expect(tocItems.length).toBe(3)
expect(tocItems[1].items[0].items[0].items.length).toBe(1) expect(tocItems[1].items[0].items[0].items.length).toBe(1)